You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

788 lines
20 KiB

/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Tobias Koppers @sokra
*/
"use strict";
const EventEmitter = require("events").EventEmitter;
const fs = require("graceful-fs");
const path = require("path");
const watchEventSource = require("./watchEventSource");
const EXISTANCE_ONLY_TIME_ENTRY = Object.freeze({});
let FS_ACCURACY = 2000;
const IS_OSX = require("os").platform() === "darwin";
const WATCHPACK_POLLING = process.env.WATCHPACK_POLLING;
const FORCE_POLLING =
`${+WATCHPACK_POLLING}` === WATCHPACK_POLLING
? +WATCHPACK_POLLING
: !!WATCHPACK_POLLING && WATCHPACK_POLLING !== "false";
function withoutCase(str) {
return str.toLowerCase();
}
function needCalls(times, callback) {
return function() {
if (--times === 0) {
return callback();
}
};
}
class Watcher extends EventEmitter {
constructor(directoryWatcher, filePath, startTime) {
super();
this.directoryWatcher = directoryWatcher;
this.path = filePath;
this.startTime = startTime && +startTime;
}
checkStartTime(mtime, initial) {
const startTime = this.startTime;
if (typeof startTime !== "number") return !initial;
return startTime <= mtime;
}
close() {
this.emit("closed");
}
}
class DirectoryWatcher extends EventEmitter {
constructor(watcherManager, directoryPath, options) {
super();
if (FORCE_POLLING) {
options.poll = FORCE_POLLING;
}
this.watcherManager = watcherManager;
this.options = options;
this.path = directoryPath;
// safeTime is the point in time after which reading is safe to be unchanged
// timestamp is a value that should be compared with another timestamp (mtime)
/** @type {Map<string, { safeTime: number, timestamp: number }} */
this.files = new Map();
/** @type {Map<string, number>} */
this.filesWithoutCase = new Map();
this.directories = new Map();
this.lastWatchEvent = 0;
this.initialScan = true;
this.ignored = options.ignored || (() => false);
this.nestedWatching = false;
this.polledWatching =
typeof options.poll === "number"
? options.poll
: options.poll
? 5007
: false;
this.timeout = undefined;
this.initialScanRemoved = new Set();
this.initialScanFinished = undefined;
/** @type {Map<string, Set<Watcher>>} */
this.watchers = new Map();
this.parentWatcher = null;
this.refs = 0;
this._activeEvents = new Map();
this.closed = false;
this.scanning = false;
this.scanAgain = false;
this.scanAgainInitial = false;
this.createWatcher();
this.doScan(true);
}
createWatcher() {
try {
if (this.polledWatching) {
this.watcher = {
close: () => {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
};
} else {
if (IS_OSX) {
this.watchInParentDirectory();
}
this.watcher = watchEventSource.watch(this.path);
this.watcher.on("change", this.onWatchEvent.bind(this));
this.watcher.on("error", this.onWatcherError.bind(this));
}
} catch (err) {
this.onWatcherError(err);
}
}
forEachWatcher(path, fn) {
const watchers = this.watchers.get(withoutCase(path));
if (watchers !== undefined) {
for (const w of watchers) {
fn(w);
}
}
}
setMissing(itemPath, initial, type) {
if (this.initialScan) {
this.initialScanRemoved.add(itemPath);
}
const oldDirectory = this.directories.get(itemPath);
if (oldDirectory) {
if (this.nestedWatching) oldDirectory.close();
this.directories.delete(itemPath);
this.forEachWatcher(itemPath, w => w.emit("remove", type));
if (!initial) {
this.forEachWatcher(this.path, w =>
w.emit("change", itemPath, null, type, initial)
);
}
}
const oldFile = this.files.get(itemPath);
if (oldFile) {
this.files.delete(itemPath);
const key = withoutCase(itemPath);
const count = this.filesWithoutCase.get(key) - 1;
if (count <= 0) {
this.filesWithoutCase.delete(key);
this.forEachWatcher(itemPath, w => w.emit("remove", type));
} else {
this.filesWithoutCase.set(key, count);
}
if (!initial) {
this.forEachWatcher(this.path, w =>
w.emit("change", itemPath, null, type, initial)
);
}
}
}
setFileTime(filePath, mtime, initial, ignoreWhenEqual, type) {
const now = Date.now();
if (this.ignored(filePath)) return;
const old = this.files.get(filePath);
let safeTime, accuracy;
if (initial) {
safeTime = Math.min(now, mtime) + FS_ACCURACY;
accuracy = FS_ACCURACY;
} else {
safeTime = now;
accuracy = 0;
if (old && old.timestamp === mtime && mtime + FS_ACCURACY < now) {
// We are sure that mtime is untouched
// This can be caused by some file attribute change
// e. g. when access time has been changed
// but the file content is untouched
return;
}
}
if (ignoreWhenEqual && old && old.timestamp === mtime) return;
this.files.set(filePath, {
safeTime,
accuracy,
timestamp: mtime
});
if (!old) {
const key = withoutCase(filePath);
const count = this.filesWithoutCase.get(key);
this.filesWithoutCase.set(key, (count || 0) + 1);
if (count !== undefined) {
// There is already a file with case-insensitive-equal name
// On a case-insensitive filesystem we may miss the renaming
// when only casing is changed.
// To be sure that our information is correct
// we trigger a rescan here
this.doScan(false);
}
this.forEachWatcher(filePath, w => {
if (!initial || w.checkStartTime(safeTime, initial)) {
w.emit("change", mtime, type);
}
});
} else if (!initial) {
this.forEachWatcher(filePath, w => w.emit("change", mtime, type));
}
this.forEachWatcher(this.path, w => {
if (!initial || w.checkStartTime(safeTime, initial)) {
w.emit("change", filePath, safeTime, type, initial);
}
});
}
setDirectory(directoryPath, birthtime, initial, type) {
if (this.ignored(directoryPath)) return;
if (directoryPath === this.path) {
if (!initial) {
this.forEachWatcher(this.path, w =>
w.emit("change", directoryPath, birthtime, type, initial)
);
}
} else {
const old = this.directories.get(directoryPath);
if (!old) {
const now = Date.now();
if (this.nestedWatching) {
this.createNestedWatcher(directoryPath);
} else {
this.directories.set(directoryPath, true);
}
let safeTime;
if (initial) {
safeTime = Math.min(now, birthtime) + FS_ACCURACY;
} else {
safeTime = now;
}
this.forEachWatcher(directoryPath, w => {
if (!initial || w.checkStartTime(safeTime, false)) {
w.emit("change", birthtime, type);
}
});
this.forEachWatcher(this.path, w => {
if (!initial || w.checkStartTime(safeTime, initial)) {
w.emit("change", directoryPath, safeTime, type, initial);
}
});
}
}
}
createNestedWatcher(directoryPath) {
const watcher = this.watcherManager.watchDirectory(directoryPath, 1);
watcher.on("change", (filePath, mtime, type, initial) => {
this.forEachWatcher(this.path, w => {
if (!initial || w.checkStartTime(mtime, initial)) {
w.emit("change", filePath, mtime, type, initial);
}
});
});
this.directories.set(directoryPath, watcher);
}
setNestedWatching(flag) {
if (this.nestedWatching !== !!flag) {
this.nestedWatching = !!flag;
if (this.nestedWatching) {
for (const directory of this.directories.keys()) {
this.createNestedWatcher(directory);
}
} else {
for (const [directory, watcher] of this.directories) {
watcher.close();
this.directories.set(directory, true);
}
}
}
}
watch(filePath, startTime) {
const key = withoutCase(filePath);
let watchers = this.watchers.get(key);
if (watchers === undefined) {
watchers = new Set();
this.watchers.set(key, watchers);
}
this.refs++;
const watcher = new Watcher(this, filePath, startTime);
watcher.on("closed", () => {
if (--this.refs <= 0) {
this.close();
return;
}
watchers.delete(watcher);
if (watchers.size === 0) {
this.watchers.delete(key);
if (this.path === filePath) this.setNestedWatching(false);
}
});
watchers.add(watcher);
let safeTime;
if (filePath === this.path) {
this.setNestedWatching(true);
safeTime = this.lastWatchEvent;
for (const entry of this.files.values()) {
fixupEntryAccuracy(entry);
safeTime = Math.max(safeTime, entry.safeTime);
}
} else {
const entry = this.files.get(filePath);
if (entry) {
fixupEntryAccuracy(entry);
safeTime = entry.safeTime;
} else {
safeTime = 0;
}
}
if (safeTime) {
if (safeTime >= startTime) {
process.nextTick(() => {
if (this.closed) return;
if (filePath === this.path) {
watcher.emit(
"change",
filePath,
safeTime,
"watch (outdated on attach)",
true
);
} else {
watcher.emit(
"change",
safeTime,
"watch (outdated on attach)",
true
);
}
});
}
} else if (this.initialScan) {
if (this.initialScanRemoved.has(filePath)) {
process.nextTick(() => {
if (this.closed) return;
watcher.emit("remove");
});
}
} else if (
filePath !== this.path &&
!this.directories.has(filePath) &&
watcher.checkStartTime(this.initialScanFinished, false)
) {
process.nextTick(() => {
if (this.closed) return;
watcher.emit("initial-missing", "watch (missing on attach)");
});
}
return watcher;
}
onWatchEvent(eventType, filename) {
if (this.closed) return;
if (!filename) {
// In some cases no filename is provided
// This seem to happen on windows
// So some event happened but we don't know which file is affected
// We have to do a full scan of the directory
this.doScan(false);
return;
}
const filePath = path.join(this.path, filename);
if (this.ignored(filePath)) return;
if (this._activeEvents.get(filename) === undefined) {
this._activeEvents.set(filename, false);
const checkStats = () => {
if (this.closed) return;
this._activeEvents.set(filename, false);
fs.lstat(filePath, (err, stats) => {
if (this.closed) return;
if (this._activeEvents.get(filename) === true) {
process.nextTick(checkStats);
return;
}
this._activeEvents.delete(filename);
// ENOENT happens when the file/directory doesn't exist
// EPERM happens when the containing directory doesn't exist
if (err) {
if (
err.code !== "ENOENT" &&
err.code !== "EPERM" &&
err.code !== "EBUSY"
) {
this.onStatsError(err);
} else {
if (filename === path.basename(this.path)) {
// This may indicate that the directory itself was removed
if (!fs.existsSync(this.path)) {
this.onDirectoryRemoved("stat failed");
}
}
}
}
this.lastWatchEvent = Date.now();
if (!stats) {
this.setMissing(filePath, false, eventType);
} else if (stats.isDirectory()) {
this.setDirectory(
filePath,
+stats.birthtime || 1,
false,
eventType
);
} else if (stats.isFile() || stats.isSymbolicLink()) {
if (stats.mtime) {
ensureFsAccuracy(stats.mtime);
}
this.setFileTime(
filePath,
+stats.mtime || +stats.ctime || 1,
false,
false,
eventType
);
}
});
};
process.nextTick(checkStats);
} else {
this._activeEvents.set(filename, true);
}
}
onWatcherError(err) {
if (this.closed) return;
if (err) {
if (err.code !== "EPERM" && err.code !== "ENOENT") {
console.error("Watchpack Error (watcher): " + err);
}
this.onDirectoryRemoved("watch error");
}
}
onStatsError(err) {
if (err) {
console.error("Watchpack Error (stats): " + err);
}
}
onScanError(err) {
if (err) {
console.error("Watchpack Error (initial scan): " + err);
}
this.onScanFinished();
}
onScanFinished() {
if (this.polledWatching) {
this.timeout = setTimeout(() => {
if (this.closed) return;
this.doScan(false);
}, this.polledWatching);
}
}
onDirectoryRemoved(reason) {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
this.watchInParentDirectory();
const type = `directory-removed (${reason})`;
for (const directory of this.directories.keys()) {
this.setMissing(directory, null, type);
}
for (const file of this.files.keys()) {
this.setMissing(file, null, type);
}
}
watchInParentDirectory() {
if (!this.parentWatcher) {
const parentDir = path.dirname(this.path);
// avoid watching in the root directory
// removing directories in the root directory is not supported
if (path.dirname(parentDir) === parentDir) return;
this.parentWatcher = this.watcherManager.watchFile(this.path, 1);
this.parentWatcher.on("change", (mtime, type) => {
if (this.closed) return;
// On non-osx platforms we don't need this watcher to detect
// directory removal, as an EPERM error indicates that
if ((!IS_OSX || this.polledWatching) && this.parentWatcher) {
this.parentWatcher.close();
this.parentWatcher = null;
}
// Try to create the watcher when parent directory is found
if (!this.watcher) {
this.createWatcher();
this.doScan(false);
// directory was created so we emit an event
this.forEachWatcher(this.path, w =>
w.emit("change", this.path, mtime, type, false)
);
}
});
this.parentWatcher.on("remove", () => {
this.onDirectoryRemoved("parent directory removed");
});
}
}
doScan(initial) {
if (this.scanning) {
if (this.scanAgain) {
if (!initial) this.scanAgainInitial = false;
} else {
this.scanAgain = true;
this.scanAgainInitial = initial;
}
return;
}
this.scanning = true;
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
process.nextTick(() => {
if (this.closed) return;
fs.readdir(this.path, (err, items) => {
if (this.closed) return;
if (err) {
if (err.code === "ENOENT" || err.code === "EPERM") {
this.onDirectoryRemoved("scan readdir failed");
} else {
this.onScanError(err);
}
this.initialScan = false;
this.initialScanFinished = Date.now();
if (initial) {
for (const watchers of this.watchers.values()) {
for (const watcher of watchers) {
if (watcher.checkStartTime(this.initialScanFinished, false)) {
watcher.emit(
"initial-missing",
"scan (parent directory missing in initial scan)"
);
}
}
}
}
if (this.scanAgain) {
this.scanAgain = false;
this.doScan(this.scanAgainInitial);
} else {
this.scanning = false;
}
return;
}
const itemPaths = new Set(
items.map(item => path.join(this.path, item.normalize("NFC")))
);
for (const file of this.files.keys()) {
if (!itemPaths.has(file)) {
this.setMissing(file, initial, "scan (missing)");
}
}
for (const directory of this.directories.keys()) {
if (!itemPaths.has(directory)) {
this.setMissing(directory, initial, "scan (missing)");
}
}
if (this.scanAgain) {
// Early repeat of scan
this.scanAgain = false;
this.doScan(initial);
return;
}
const itemFinished = needCalls(itemPaths.size + 1, () => {
if (this.closed) return;
this.initialScan = false;
this.initialScanRemoved = null;
this.initialScanFinished = Date.now();
if (initial) {
const missingWatchers = new Map(this.watchers);
missingWatchers.delete(withoutCase(this.path));
for (const item of itemPaths) {
missingWatchers.delete(withoutCase(item));
}
for (const watchers of missingWatchers.values()) {
for (const watcher of watchers) {
if (watcher.checkStartTime(this.initialScanFinished, false)) {
watcher.emit(
"initial-missing",
"scan (missing in initial scan)"
);
}
}
}
}
if (this.scanAgain) {
this.scanAgain = false;
this.doScan(this.scanAgainInitial);
} else {
this.scanning = false;
this.onScanFinished();
}
});
for (const itemPath of itemPaths) {
fs.lstat(itemPath, (err2, stats) => {
if (this.closed) return;
if (err2) {
if (
err2.code === "ENOENT" ||
err2.code === "EPERM" ||
err2.code === "EACCES" ||
err2.code === "EBUSY"
) {
this.setMissing(itemPath, initial, "scan (" + err2.code + ")");
} else {
this.onScanError(err2);
}
itemFinished();
return;
}
if (stats.isFile() || stats.isSymbolicLink()) {
if (stats.mtime) {
ensureFsAccuracy(stats.mtime);
}
this.setFileTime(
itemPath,
+stats.mtime || +stats.ctime || 1,
initial,
true,
"scan (file)"
);
} else if (stats.isDirectory()) {
if (!initial || !this.directories.has(itemPath))
this.setDirectory(
itemPath,
+stats.birthtime || 1,
initial,
"scan (dir)"
);
}
itemFinished();
});
}
itemFinished();
});
});
}
getTimes() {
const obj = Object.create(null);
let safeTime = this.lastWatchEvent;
for (const [file, entry] of this.files) {
fixupEntryAccuracy(entry);
safeTime = Math.max(safeTime, entry.safeTime);
obj[file] = Math.max(entry.safeTime, entry.timestamp);
}
if (this.nestedWatching) {
for (const w of this.directories.values()) {
const times = w.directoryWatcher.getTimes();
for (const file of Object.keys(times)) {
const time = times[file];
safeTime = Math.max(safeTime, time);
obj[file] = time;
}
}
obj[this.path] = safeTime;
}
if (!this.initialScan) {
for (const watchers of this.watchers.values()) {
for (const watcher of watchers) {
const path = watcher.path;
if (!Object.prototype.hasOwnProperty.call(obj, path)) {
obj[path] = null;
}
}
}
}
return obj;
}
collectTimeInfoEntries(fileTimestamps, directoryTimestamps) {
let safeTime = this.lastWatchEvent;
for (const [file, entry] of this.files) {
fixupEntryAccuracy(entry);
safeTime = Math.max(safeTime, entry.safeTime);
fileTimestamps.set(file, entry);
}
if (this.nestedWatching) {
for (const w of this.directories.values()) {
safeTime = Math.max(
safeTime,
w.directoryWatcher.collectTimeInfoEntries(
fileTimestamps,
directoryTimestamps
)
);
}
fileTimestamps.set(this.path, EXISTANCE_ONLY_TIME_ENTRY);
directoryTimestamps.set(this.path, {
safeTime
});
} else {
for (const dir of this.directories.keys()) {
// No additional info about this directory
// but maybe another DirectoryWatcher has info
fileTimestamps.set(dir, EXISTANCE_ONLY_TIME_ENTRY);
if (!directoryTimestamps.has(dir))
directoryTimestamps.set(dir, EXISTANCE_ONLY_TIME_ENTRY);
}
fileTimestamps.set(this.path, EXISTANCE_ONLY_TIME_ENTRY);
directoryTimestamps.set(this.path, EXISTANCE_ONLY_TIME_ENTRY);
}
if (!this.initialScan) {
for (const watchers of this.watchers.values()) {
for (const watcher of watchers) {
const path = watcher.path;
if (!fileTimestamps.has(path)) {
fileTimestamps.set(path, null);
}
}
}
}
return safeTime;
}
close() {
this.closed = true;
this.initialScan = false;
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
if (this.nestedWatching) {
for (const w of this.directories.values()) {
w.close();
}
this.directories.clear();
}
if (this.parentWatcher) {
this.parentWatcher.close();
this.parentWatcher = null;
}
this.emit("closed");
}
}
module.exports = DirectoryWatcher;
module.exports.EXISTANCE_ONLY_TIME_ENTRY = EXISTANCE_ONLY_TIME_ENTRY;
function fixupEntryAccuracy(entry) {
if (entry.accuracy > FS_ACCURACY) {
entry.safeTime = entry.safeTime - entry.accuracy + FS_ACCURACY;
entry.accuracy = FS_ACCURACY;
}
}
function ensureFsAccuracy(mtime) {
if (!mtime) return;
if (FS_ACCURACY > 1 && mtime % 1 !== 0) FS_ACCURACY = 1;
else if (FS_ACCURACY > 10 && mtime % 10 !== 0) FS_ACCURACY = 10;
else if (FS_ACCURACY > 100 && mtime % 100 !== 0) FS_ACCURACY = 100;
else if (FS_ACCURACY > 1000 && mtime % 1000 !== 0) FS_ACCURACY = 1000;
}