DirectoryWatcher.js (20474B)
1 /* 2 MIT License http://www.opensource.org/licenses/mit-license.php 3 Author Tobias Koppers @sokra 4 */ 5 "use strict"; 6 7 const EventEmitter = require("events").EventEmitter; 8 const fs = require("graceful-fs"); 9 const path = require("path"); 10 11 const watchEventSource = require("./watchEventSource"); 12 13 const EXISTANCE_ONLY_TIME_ENTRY = Object.freeze({}); 14 15 let FS_ACCURACY = 1000; 16 17 const IS_OSX = require("os").platform() === "darwin"; 18 const WATCHPACK_POLLING = process.env.WATCHPACK_POLLING; 19 const FORCE_POLLING = 20 `${+WATCHPACK_POLLING}` === WATCHPACK_POLLING 21 ? +WATCHPACK_POLLING 22 : !!WATCHPACK_POLLING && WATCHPACK_POLLING !== "false"; 23 24 function withoutCase(str) { 25 return str.toLowerCase(); 26 } 27 28 function needCalls(times, callback) { 29 return function() { 30 if (--times === 0) { 31 return callback(); 32 } 33 }; 34 } 35 36 class Watcher extends EventEmitter { 37 constructor(directoryWatcher, filePath, startTime) { 38 super(); 39 this.directoryWatcher = directoryWatcher; 40 this.path = filePath; 41 this.startTime = startTime && +startTime; 42 } 43 44 checkStartTime(mtime, initial) { 45 const startTime = this.startTime; 46 if (typeof startTime !== "number") return !initial; 47 return startTime <= mtime; 48 } 49 50 close() { 51 this.emit("closed"); 52 } 53 } 54 55 class DirectoryWatcher extends EventEmitter { 56 constructor(watcherManager, directoryPath, options) { 57 super(); 58 if (FORCE_POLLING) { 59 options.poll = FORCE_POLLING; 60 } 61 this.watcherManager = watcherManager; 62 this.options = options; 63 this.path = directoryPath; 64 // safeTime is the point in time after which reading is safe to be unchanged 65 // timestamp is a value that should be compared with another timestamp (mtime) 66 /** @type {Map<string, { safeTime: number, timestamp: number }} */ 67 this.files = new Map(); 68 /** @type {Map<string, number>} */ 69 this.filesWithoutCase = new Map(); 70 this.directories = new Map(); 71 this.lastWatchEvent = 0; 72 this.initialScan = true; 73 this.ignored = options.ignored || (() => false); 74 this.nestedWatching = false; 75 this.polledWatching = 76 typeof options.poll === "number" 77 ? options.poll 78 : options.poll 79 ? 5007 80 : false; 81 this.timeout = undefined; 82 this.initialScanRemoved = new Set(); 83 this.initialScanFinished = undefined; 84 /** @type {Map<string, Set<Watcher>>} */ 85 this.watchers = new Map(); 86 this.parentWatcher = null; 87 this.refs = 0; 88 this._activeEvents = new Map(); 89 this.closed = false; 90 this.scanning = false; 91 this.scanAgain = false; 92 this.scanAgainInitial = false; 93 94 this.createWatcher(); 95 this.doScan(true); 96 } 97 98 createWatcher() { 99 try { 100 if (this.polledWatching) { 101 this.watcher = { 102 close: () => { 103 if (this.timeout) { 104 clearTimeout(this.timeout); 105 this.timeout = undefined; 106 } 107 } 108 }; 109 } else { 110 if (IS_OSX) { 111 this.watchInParentDirectory(); 112 } 113 this.watcher = watchEventSource.watch(this.path); 114 this.watcher.on("change", this.onWatchEvent.bind(this)); 115 this.watcher.on("error", this.onWatcherError.bind(this)); 116 } 117 } catch (err) { 118 this.onWatcherError(err); 119 } 120 } 121 122 forEachWatcher(path, fn) { 123 const watchers = this.watchers.get(withoutCase(path)); 124 if (watchers !== undefined) { 125 for (const w of watchers) { 126 fn(w); 127 } 128 } 129 } 130 131 setMissing(itemPath, initial, type) { 132 if (this.initialScan) { 133 this.initialScanRemoved.add(itemPath); 134 } 135 136 const oldDirectory = this.directories.get(itemPath); 137 if (oldDirectory) { 138 if (this.nestedWatching) oldDirectory.close(); 139 this.directories.delete(itemPath); 140 141 this.forEachWatcher(itemPath, w => w.emit("remove", type)); 142 if (!initial) { 143 this.forEachWatcher(this.path, w => 144 w.emit("change", itemPath, null, type, initial) 145 ); 146 } 147 } 148 149 const oldFile = this.files.get(itemPath); 150 if (oldFile) { 151 this.files.delete(itemPath); 152 const key = withoutCase(itemPath); 153 const count = this.filesWithoutCase.get(key) - 1; 154 if (count <= 0) { 155 this.filesWithoutCase.delete(key); 156 this.forEachWatcher(itemPath, w => w.emit("remove", type)); 157 } else { 158 this.filesWithoutCase.set(key, count); 159 } 160 161 if (!initial) { 162 this.forEachWatcher(this.path, w => 163 w.emit("change", itemPath, null, type, initial) 164 ); 165 } 166 } 167 } 168 169 setFileTime(filePath, mtime, initial, ignoreWhenEqual, type) { 170 const now = Date.now(); 171 172 if (this.ignored(filePath)) return; 173 174 const old = this.files.get(filePath); 175 176 let safeTime, accuracy; 177 if (initial) { 178 safeTime = Math.min(now, mtime) + FS_ACCURACY; 179 accuracy = FS_ACCURACY; 180 } else { 181 safeTime = now; 182 accuracy = 0; 183 184 if (old && old.timestamp === mtime && mtime + FS_ACCURACY < now - 1000) { 185 // We are sure that mtime is untouched 186 // This can be caused by some file attribute change 187 // e. g. when access time has been changed 188 // but the file content is untouched 189 return; 190 } 191 } 192 193 if (ignoreWhenEqual && old && old.timestamp === mtime) return; 194 195 this.files.set(filePath, { 196 safeTime, 197 accuracy, 198 timestamp: mtime 199 }); 200 201 if (!old) { 202 const key = withoutCase(filePath); 203 const count = this.filesWithoutCase.get(key); 204 this.filesWithoutCase.set(key, (count || 0) + 1); 205 if (count !== undefined) { 206 // There is already a file with case-insensitive-equal name 207 // On a case-insensitive filesystem we may miss the renaming 208 // when only casing is changed. 209 // To be sure that our information is correct 210 // we trigger a rescan here 211 this.doScan(false); 212 } 213 214 this.forEachWatcher(filePath, w => { 215 if (!initial || w.checkStartTime(safeTime, initial)) { 216 w.emit("change", mtime, type); 217 } 218 }); 219 } else if (!initial) { 220 this.forEachWatcher(filePath, w => w.emit("change", mtime, type)); 221 } 222 this.forEachWatcher(this.path, w => { 223 if (!initial || w.checkStartTime(safeTime, initial)) { 224 w.emit("change", filePath, safeTime, type, initial); 225 } 226 }); 227 } 228 229 setDirectory(directoryPath, birthtime, initial, type) { 230 if (this.ignored(directoryPath)) return; 231 if (directoryPath === this.path) { 232 if (!initial) { 233 this.forEachWatcher(this.path, w => 234 w.emit("change", directoryPath, birthtime, type, initial) 235 ); 236 } 237 } else { 238 const old = this.directories.get(directoryPath); 239 if (!old) { 240 const now = Date.now(); 241 242 if (this.nestedWatching) { 243 this.createNestedWatcher(directoryPath); 244 } else { 245 this.directories.set(directoryPath, true); 246 } 247 248 let safeTime; 249 if (initial) { 250 safeTime = Math.min(now, birthtime) + FS_ACCURACY; 251 } else { 252 safeTime = now; 253 } 254 255 this.forEachWatcher(directoryPath, w => { 256 if (!initial || w.checkStartTime(safeTime, false)) { 257 w.emit("change", birthtime, type); 258 } 259 }); 260 this.forEachWatcher(this.path, w => { 261 if (!initial || w.checkStartTime(safeTime, initial)) { 262 w.emit("change", directoryPath, safeTime, type, initial); 263 } 264 }); 265 } 266 } 267 } 268 269 createNestedWatcher(directoryPath) { 270 const watcher = this.watcherManager.watchDirectory(directoryPath, 1); 271 watcher.on("change", (filePath, mtime, type, initial) => { 272 this.forEachWatcher(this.path, w => { 273 if (!initial || w.checkStartTime(mtime, initial)) { 274 w.emit("change", filePath, mtime, type, initial); 275 } 276 }); 277 }); 278 this.directories.set(directoryPath, watcher); 279 } 280 281 setNestedWatching(flag) { 282 if (this.nestedWatching !== !!flag) { 283 this.nestedWatching = !!flag; 284 if (this.nestedWatching) { 285 for (const directory of this.directories.keys()) { 286 this.createNestedWatcher(directory); 287 } 288 } else { 289 for (const [directory, watcher] of this.directories) { 290 watcher.close(); 291 this.directories.set(directory, true); 292 } 293 } 294 } 295 } 296 297 watch(filePath, startTime) { 298 const key = withoutCase(filePath); 299 let watchers = this.watchers.get(key); 300 if (watchers === undefined) { 301 watchers = new Set(); 302 this.watchers.set(key, watchers); 303 } 304 this.refs++; 305 const watcher = new Watcher(this, filePath, startTime); 306 watcher.on("closed", () => { 307 if (--this.refs <= 0) { 308 this.close(); 309 return; 310 } 311 watchers.delete(watcher); 312 if (watchers.size === 0) { 313 this.watchers.delete(key); 314 if (this.path === filePath) this.setNestedWatching(false); 315 } 316 }); 317 watchers.add(watcher); 318 let safeTime; 319 if (filePath === this.path) { 320 this.setNestedWatching(true); 321 safeTime = this.lastWatchEvent; 322 for (const entry of this.files.values()) { 323 fixupEntryAccuracy(entry); 324 safeTime = Math.max(safeTime, entry.safeTime); 325 } 326 } else { 327 const entry = this.files.get(filePath); 328 if (entry) { 329 fixupEntryAccuracy(entry); 330 safeTime = entry.safeTime; 331 } else { 332 safeTime = 0; 333 } 334 } 335 if (safeTime) { 336 if (safeTime >= startTime) { 337 process.nextTick(() => { 338 if (this.closed) return; 339 if (filePath === this.path) { 340 watcher.emit( 341 "change", 342 filePath, 343 safeTime, 344 "watch (outdated on attach)", 345 true 346 ); 347 } else { 348 watcher.emit( 349 "change", 350 safeTime, 351 "watch (outdated on attach)", 352 true 353 ); 354 } 355 }); 356 } 357 } else if (this.initialScan) { 358 if (this.initialScanRemoved.has(filePath)) { 359 process.nextTick(() => { 360 if (this.closed) return; 361 watcher.emit("remove"); 362 }); 363 } 364 } else if ( 365 !this.directories.has(filePath) && 366 watcher.checkStartTime(this.initialScanFinished, false) 367 ) { 368 process.nextTick(() => { 369 if (this.closed) return; 370 watcher.emit("initial-missing", "watch (missing on attach)"); 371 }); 372 } 373 return watcher; 374 } 375 376 onWatchEvent(eventType, filename) { 377 if (this.closed) return; 378 if (!filename) { 379 // In some cases no filename is provided 380 // This seem to happen on windows 381 // So some event happened but we don't know which file is affected 382 // We have to do a full scan of the directory 383 this.doScan(false); 384 return; 385 } 386 387 const filePath = path.join(this.path, filename); 388 if (this.ignored(filePath)) return; 389 390 if (this._activeEvents.get(filename) === undefined) { 391 this._activeEvents.set(filename, false); 392 const checkStats = () => { 393 if (this.closed) return; 394 this._activeEvents.set(filename, false); 395 fs.lstat(filePath, (err, stats) => { 396 if (this.closed) return; 397 if (this._activeEvents.get(filename) === true) { 398 process.nextTick(checkStats); 399 return; 400 } 401 this._activeEvents.delete(filename); 402 // ENOENT happens when the file/directory doesn't exist 403 // EPERM happens when the containing directory doesn't exist 404 if (err) { 405 if ( 406 err.code !== "ENOENT" && 407 err.code !== "EPERM" && 408 err.code !== "EBUSY" 409 ) { 410 this.onStatsError(err); 411 } else { 412 if (filename === path.basename(this.path)) { 413 // This may indicate that the directory itself was removed 414 if (!fs.existsSync(this.path)) { 415 this.onDirectoryRemoved("stat failed"); 416 } 417 } 418 } 419 } 420 this.lastWatchEvent = Date.now(); 421 if (!stats) { 422 this.setMissing(filePath, false, eventType); 423 } else if (stats.isDirectory()) { 424 this.setDirectory( 425 filePath, 426 +stats.birthtime || 1, 427 false, 428 eventType 429 ); 430 } else if (stats.isFile() || stats.isSymbolicLink()) { 431 if (stats.mtime) { 432 ensureFsAccuracy(stats.mtime); 433 } 434 this.setFileTime( 435 filePath, 436 +stats.mtime || +stats.ctime || 1, 437 false, 438 false, 439 eventType 440 ); 441 } 442 }); 443 }; 444 process.nextTick(checkStats); 445 } else { 446 this._activeEvents.set(filename, true); 447 } 448 } 449 450 onWatcherError(err) { 451 if (this.closed) return; 452 if (err) { 453 if (err.code !== "EPERM" && err.code !== "ENOENT") { 454 console.error("Watchpack Error (watcher): " + err); 455 } 456 this.onDirectoryRemoved("watch error"); 457 } 458 } 459 460 onStatsError(err) { 461 if (err) { 462 console.error("Watchpack Error (stats): " + err); 463 } 464 } 465 466 onScanError(err) { 467 if (err) { 468 console.error("Watchpack Error (initial scan): " + err); 469 } 470 this.onScanFinished(); 471 } 472 473 onScanFinished() { 474 if (this.polledWatching) { 475 this.timeout = setTimeout(() => { 476 if (this.closed) return; 477 this.doScan(false); 478 }, this.polledWatching); 479 } 480 } 481 482 onDirectoryRemoved(reason) { 483 if (this.watcher) { 484 this.watcher.close(); 485 this.watcher = null; 486 } 487 this.watchInParentDirectory(); 488 const type = `directory-removed (${reason})`; 489 for (const directory of this.directories.keys()) { 490 this.setMissing(directory, null, type); 491 } 492 for (const file of this.files.keys()) { 493 this.setMissing(file, null, type); 494 } 495 } 496 497 watchInParentDirectory() { 498 if (!this.parentWatcher) { 499 const parentDir = path.dirname(this.path); 500 // avoid watching in the root directory 501 // removing directories in the root directory is not supported 502 if (path.dirname(parentDir) === parentDir) return; 503 504 this.parentWatcher = this.watcherManager.watchFile(this.path, 1); 505 this.parentWatcher.on("change", (mtime, type) => { 506 if (this.closed) return; 507 508 // On non-osx platforms we don't need this watcher to detect 509 // directory removal, as an EPERM error indicates that 510 if ((!IS_OSX || this.polledWatching) && this.parentWatcher) { 511 this.parentWatcher.close(); 512 this.parentWatcher = null; 513 } 514 // Try to create the watcher when parent directory is found 515 if (!this.watcher) { 516 this.createWatcher(); 517 this.doScan(false); 518 519 // directory was created so we emit an event 520 this.forEachWatcher(this.path, w => 521 w.emit("change", this.path, mtime, type, false) 522 ); 523 } 524 }); 525 this.parentWatcher.on("remove", () => { 526 this.onDirectoryRemoved("parent directory removed"); 527 }); 528 } 529 } 530 531 doScan(initial) { 532 if (this.scanning) { 533 if (this.scanAgain) { 534 if (!initial) this.scanAgainInitial = false; 535 } else { 536 this.scanAgain = true; 537 this.scanAgainInitial = initial; 538 } 539 return; 540 } 541 this.scanning = true; 542 if (this.timeout) { 543 clearTimeout(this.timeout); 544 this.timeout = undefined; 545 } 546 process.nextTick(() => { 547 if (this.closed) return; 548 fs.readdir(this.path, (err, items) => { 549 if (this.closed) return; 550 if (err) { 551 if (err.code === "ENOENT" || err.code === "EPERM") { 552 this.onDirectoryRemoved("scan readdir failed"); 553 } else { 554 this.onScanError(err); 555 } 556 this.initialScan = false; 557 this.initialScanFinished = Date.now(); 558 if (initial) { 559 for (const watchers of this.watchers.values()) { 560 for (const watcher of watchers) { 561 if (watcher.checkStartTime(this.initialScanFinished, false)) { 562 watcher.emit( 563 "initial-missing", 564 "scan (parent directory missing in initial scan)" 565 ); 566 } 567 } 568 } 569 } 570 if (this.scanAgain) { 571 this.scanAgain = false; 572 this.doScan(this.scanAgainInitial); 573 } else { 574 this.scanning = false; 575 } 576 return; 577 } 578 const itemPaths = new Set( 579 items.map(item => path.join(this.path, item.normalize("NFC"))) 580 ); 581 for (const file of this.files.keys()) { 582 if (!itemPaths.has(file)) { 583 this.setMissing(file, initial, "scan (missing)"); 584 } 585 } 586 for (const directory of this.directories.keys()) { 587 if (!itemPaths.has(directory)) { 588 this.setMissing(directory, initial, "scan (missing)"); 589 } 590 } 591 if (this.scanAgain) { 592 // Early repeat of scan 593 this.scanAgain = false; 594 this.doScan(initial); 595 return; 596 } 597 const itemFinished = needCalls(itemPaths.size + 1, () => { 598 if (this.closed) return; 599 this.initialScan = false; 600 this.initialScanRemoved = null; 601 this.initialScanFinished = Date.now(); 602 if (initial) { 603 const missingWatchers = new Map(this.watchers); 604 missingWatchers.delete(withoutCase(this.path)); 605 for (const item of itemPaths) { 606 missingWatchers.delete(withoutCase(item)); 607 } 608 for (const watchers of missingWatchers.values()) { 609 for (const watcher of watchers) { 610 if (watcher.checkStartTime(this.initialScanFinished, false)) { 611 watcher.emit( 612 "initial-missing", 613 "scan (missing in initial scan)" 614 ); 615 } 616 } 617 } 618 } 619 if (this.scanAgain) { 620 this.scanAgain = false; 621 this.doScan(this.scanAgainInitial); 622 } else { 623 this.scanning = false; 624 this.onScanFinished(); 625 } 626 }); 627 for (const itemPath of itemPaths) { 628 fs.lstat(itemPath, (err2, stats) => { 629 if (this.closed) return; 630 if (err2) { 631 if ( 632 err2.code === "ENOENT" || 633 err2.code === "EPERM" || 634 err2.code === "EACCES" || 635 err2.code === "EBUSY" 636 ) { 637 this.setMissing(itemPath, initial, "scan (" + err2.code + ")"); 638 } else { 639 this.onScanError(err2); 640 } 641 itemFinished(); 642 return; 643 } 644 if (stats.isFile() || stats.isSymbolicLink()) { 645 if (stats.mtime) { 646 ensureFsAccuracy(stats.mtime); 647 } 648 this.setFileTime( 649 itemPath, 650 +stats.mtime || +stats.ctime || 1, 651 initial, 652 true, 653 "scan (file)" 654 ); 655 } else if (stats.isDirectory()) { 656 if (!initial || !this.directories.has(itemPath)) 657 this.setDirectory( 658 itemPath, 659 +stats.birthtime || 1, 660 initial, 661 "scan (dir)" 662 ); 663 } 664 itemFinished(); 665 }); 666 } 667 itemFinished(); 668 }); 669 }); 670 } 671 672 getTimes() { 673 const obj = Object.create(null); 674 let safeTime = this.lastWatchEvent; 675 for (const [file, entry] of this.files) { 676 fixupEntryAccuracy(entry); 677 safeTime = Math.max(safeTime, entry.safeTime); 678 obj[file] = Math.max(entry.safeTime, entry.timestamp); 679 } 680 if (this.nestedWatching) { 681 for (const w of this.directories.values()) { 682 const times = w.directoryWatcher.getTimes(); 683 for (const file of Object.keys(times)) { 684 const time = times[file]; 685 safeTime = Math.max(safeTime, time); 686 obj[file] = time; 687 } 688 } 689 obj[this.path] = safeTime; 690 } 691 if (!this.initialScan) { 692 for (const watchers of this.watchers.values()) { 693 for (const watcher of watchers) { 694 const path = watcher.path; 695 if (!Object.prototype.hasOwnProperty.call(obj, path)) { 696 obj[path] = null; 697 } 698 } 699 } 700 } 701 return obj; 702 } 703 704 collectTimeInfoEntries(fileTimestamps, directoryTimestamps) { 705 let safeTime = this.lastWatchEvent; 706 for (const [file, entry] of this.files) { 707 fixupEntryAccuracy(entry); 708 safeTime = Math.max(safeTime, entry.safeTime); 709 fileTimestamps.set(file, entry); 710 } 711 if (this.nestedWatching) { 712 for (const w of this.directories.values()) { 713 safeTime = Math.max( 714 safeTime, 715 w.directoryWatcher.collectTimeInfoEntries( 716 fileTimestamps, 717 directoryTimestamps 718 ) 719 ); 720 } 721 fileTimestamps.set(this.path, EXISTANCE_ONLY_TIME_ENTRY); 722 directoryTimestamps.set(this.path, { 723 safeTime 724 }); 725 } else { 726 for (const dir of this.directories.keys()) { 727 // No additional info about this directory 728 // but maybe another DirectoryWatcher has info 729 fileTimestamps.set(dir, EXISTANCE_ONLY_TIME_ENTRY); 730 if (!directoryTimestamps.has(dir)) 731 directoryTimestamps.set(dir, EXISTANCE_ONLY_TIME_ENTRY); 732 } 733 fileTimestamps.set(this.path, EXISTANCE_ONLY_TIME_ENTRY); 734 directoryTimestamps.set(this.path, EXISTANCE_ONLY_TIME_ENTRY); 735 } 736 if (!this.initialScan) { 737 for (const watchers of this.watchers.values()) { 738 for (const watcher of watchers) { 739 const path = watcher.path; 740 if (!fileTimestamps.has(path)) { 741 fileTimestamps.set(path, null); 742 } 743 } 744 } 745 } 746 return safeTime; 747 } 748 749 close() { 750 this.closed = true; 751 this.initialScan = false; 752 if (this.watcher) { 753 this.watcher.close(); 754 this.watcher = null; 755 } 756 if (this.nestedWatching) { 757 for (const w of this.directories.values()) { 758 w.close(); 759 } 760 this.directories.clear(); 761 } 762 if (this.parentWatcher) { 763 this.parentWatcher.close(); 764 this.parentWatcher = null; 765 } 766 this.emit("closed"); 767 } 768 } 769 770 module.exports = DirectoryWatcher; 771 module.exports.EXISTANCE_ONLY_TIME_ENTRY = EXISTANCE_ONLY_TIME_ENTRY; 772 773 function fixupEntryAccuracy(entry) { 774 if (entry.accuracy > FS_ACCURACY) { 775 entry.safeTime = entry.safeTime - entry.accuracy + FS_ACCURACY; 776 entry.accuracy = FS_ACCURACY; 777 } 778 } 779 780 function ensureFsAccuracy(mtime) { 781 if (!mtime) return; 782 if (FS_ACCURACY > 1 && mtime % 1 !== 0) FS_ACCURACY = 1; 783 else if (FS_ACCURACY > 10 && mtime % 10 !== 0) FS_ACCURACY = 10; 784 else if (FS_ACCURACY > 100 && mtime % 100 !== 0) FS_ACCURACY = 100; 785 }