Skip to content

Commit 51fb7e3

Browse files
robhoganfacebook-github-bot
authored andcommitted
NodeWatcher - fix scan / add race, minimise recursive watch delay
Summary: This addresses some inconsistencies and bugs in the `NodeWatcher` (`fs.watch`-based fallback for non-Watchman, non-macOS), so we can improve test strictness ahead of other changes. ## Scan/'add' race As noted in the deleted test workarounds, there was previously a race when creating a subtree between 1. The scan of a subtree's contents in when processing the 'add' event for a root. 2. The 'add' event fired at or under the root for a new file or directory, if a watch has already been established. This could result in multiple add events emitted for a given file or directory. This diff makes some simple changes to ensure that an add event is only emitted for previously-unwatched directories or previously-unregistered files (`_watchdir` and `_register` return booleans). ## Watch initialisation delays A distinct but related issue occurred when a file was deleted from a new directory - the deletion may be missed if a watch hasn't yet been established on a new directory. This turned out to be at least in part because we perform the recursive watch by intercepting directory addition events *within* a debounce wrapper (ie, after an explicit delay). This diff moves the traversal to begin immediately on processing a directory addition. Reviewed By: jacdebug Differential Revision: D41713791 fbshipit-source-id: 714870799f3819a07e7cd96a6d8c87328aaa9ca6
1 parent 528c891 commit 51fb7e3

File tree

3 files changed

+80
-79
lines changed

3 files changed

+80
-79
lines changed

packages/metro-file-map/src/watchers/NodeWatcher.js

Lines changed: 77 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@ const fs = require('fs');
2525
const platform = require('os').platform();
2626
const path = require('path');
2727

28-
const DEFAULT_DELAY = common.DEFAULT_DELAY;
2928
const CHANGE_EVENT = common.CHANGE_EVENT;
3029
const DELETE_EVENT = common.DELETE_EVENT;
3130
const ADD_EVENT = common.ADD_EVENT;
3231
const ALL_EVENT = common.ALL_EVENT;
3332

33+
/**
34+
* This setting delays all events. It suppresses 'change' events that
35+
* immediately follow an 'add', and debounces successive 'change' events to
36+
* only emit the latest.
37+
*/
38+
const DEBOUNCE_MS = 100;
39+
3440
module.exports = class NodeWatcher extends EventEmitter {
35-
_changeTimers: {[key: string]: TimeoutID, __proto__: null};
41+
_changeTimers: Map<string, TimeoutID> = new Map();
3642
_dirRegistry: {
3743
[directory: string]: {[file: string]: true, __proto__: null},
3844
__proto__: null,
@@ -52,14 +58,15 @@ module.exports = class NodeWatcher extends EventEmitter {
5258
common.assignOptions(this, opts);
5359

5460
this.watched = Object.create(null);
55-
this._changeTimers = Object.create(null);
5661
this._dirRegistry = Object.create(null);
5762
this.root = path.resolve(dir);
5863

5964
this._watchdir(this.root);
6065
common.recReaddir(
6166
this.root,
62-
this._watchdir,
67+
dir => {
68+
this._watchdir(dir);
69+
},
6370
filename => {
6471
this._register(filename);
6572
},
@@ -82,21 +89,27 @@ module.exports = class NodeWatcher extends EventEmitter {
8289
* filename => true
8390
* }
8491
* }
92+
*
93+
* Return false if ignored or already registered.
8594
*/
8695
_register(filepath: string): boolean {
96+
const dir = path.dirname(filepath);
97+
const filename = path.basename(filepath);
98+
if (this._dirRegistry[dir] && this._dirRegistry[dir][filename]) {
99+
return false;
100+
}
101+
87102
const relativePath = path.relative(this.root, filepath);
88103
if (
89104
!common.isFileIncluded(this.globs, this.dot, this.doIgnore, relativePath)
90105
) {
91106
return false;
92107
}
93108

94-
const dir = path.dirname(filepath);
95109
if (!this._dirRegistry[dir]) {
96110
this._dirRegistry[dir] = Object.create(null);
97111
}
98112

99-
const filename = path.basename(filepath);
100113
this._dirRegistry[dir][filename] = true;
101114

102115
return true;
@@ -146,11 +159,10 @@ module.exports = class NodeWatcher extends EventEmitter {
146159
/**
147160
* Watch a directory.
148161
*/
149-
_watchdir: string => void = (dir: string) => {
162+
_watchdir: string => boolean = (dir: string) => {
150163
if (this.watched[dir]) {
151-
return;
164+
return false;
152165
}
153-
154166
const watcher = fs.watch(dir, {persistent: true}, (event, filename) =>
155167
this._normalizeChange(dir, event, filename),
156168
);
@@ -161,6 +173,7 @@ module.exports = class NodeWatcher extends EventEmitter {
161173
if (this.root !== dir) {
162174
this._register(dir);
163175
}
176+
return true;
164177
};
165178

166179
/**
@@ -249,24 +262,43 @@ module.exports = class NodeWatcher extends EventEmitter {
249262
if (error && error.code !== 'ENOENT') {
250263
this.emit('error', error);
251264
} else if (!error && stat.isDirectory()) {
252-
// win32 emits usless change events on dirs.
253-
if (event !== 'change') {
254-
this._watchdir(fullPath);
255-
if (
256-
stat &&
257-
common.isFileIncluded(
258-
this.globs,
259-
this.dot,
260-
this.doIgnore,
261-
relativePath,
262-
)
263-
) {
264-
this._emitEvent(ADD_EVENT, relativePath, {
265-
modifiedTime: stat.mtime.getTime(),
266-
size: stat.size,
267-
type: 'd',
268-
});
269-
}
265+
if (event === 'change') {
266+
// win32 emits usless change events on dirs.
267+
return;
268+
}
269+
if (
270+
stat &&
271+
common.isFileIncluded(
272+
this.globs,
273+
this.dot,
274+
this.doIgnore,
275+
relativePath,
276+
)
277+
) {
278+
common.recReaddir(
279+
path.resolve(this.root, relativePath),
280+
(dir, stats) => {
281+
if (this._watchdir(dir)) {
282+
this._emitEvent(ADD_EVENT, path.relative(this.root, dir), {
283+
modifiedTime: stats.mtime.getTime(),
284+
size: stats.size,
285+
type: 'd',
286+
});
287+
}
288+
},
289+
(file, stats) => {
290+
if (this._register(file)) {
291+
this._emitEvent(ADD_EVENT, path.relative(this.root, file), {
292+
modifiedTime: stats.mtime.getTime(),
293+
size: stats.size,
294+
type: 'f',
295+
});
296+
}
297+
},
298+
function endCallback() {},
299+
this._checkedEmitError,
300+
this.ignored,
301+
);
270302
}
271303
} else {
272304
const registered = this._registered(fullPath);
@@ -300,48 +332,31 @@ module.exports = class NodeWatcher extends EventEmitter {
300332
}
301333

302334
/**
303-
* Triggers a 'change' event after debounding it to take care of duplicate
304-
* events on os x.
335+
* Emits the given event after debouncing, to 1) suppress 'change' events
336+
* immediately following an 'add', and 2) to only emit the latest 'change'
337+
* event when received in quick succession for a given file.
338+
*
339+
* See also note above for DEBOUNCE_MS.
305340
*/
306341
_emitEvent(type: string, file: string, metadata?: ChangeEventMetadata) {
307342
const key = type + '-' + file;
308343
const addKey = ADD_EVENT + '-' + file;
309-
if (type === CHANGE_EVENT && this._changeTimers[addKey]) {
344+
if (type === CHANGE_EVENT && this._changeTimers.has(addKey)) {
310345
// Ignore the change event that is immediately fired after an add event.
311346
// (This happens on Linux).
312347
return;
313348
}
314-
clearTimeout(this._changeTimers[key]);
315-
this._changeTimers[key] = setTimeout(() => {
316-
delete this._changeTimers[key];
317-
if (type === ADD_EVENT && metadata?.type === 'd') {
318-
// Recursively emit add events and watch for sub-files/folders
319-
common.recReaddir(
320-
path.resolve(this.root, file),
321-
(dir, stats) => {
322-
this._watchdir(dir);
323-
this._rawEmitEvent(ADD_EVENT, path.relative(this.root, dir), {
324-
modifiedTime: stats.mtime.getTime(),
325-
size: stats.size,
326-
type: 'd',
327-
});
328-
},
329-
(file, stats) => {
330-
this._register(file);
331-
this._rawEmitEvent(ADD_EVENT, path.relative(this.root, file), {
332-
modifiedTime: stats.mtime.getTime(),
333-
size: stats.size,
334-
type: 'f',
335-
});
336-
},
337-
function endCallback() {},
338-
this._checkedEmitError,
339-
this.ignored,
340-
);
341-
} else {
349+
const existingTimer = this._changeTimers.get(key);
350+
if (existingTimer) {
351+
clearTimeout(existingTimer);
352+
}
353+
this._changeTimers.set(
354+
key,
355+
setTimeout(() => {
356+
this._changeTimers.delete(key);
342357
this._rawEmitEvent(type, file, metadata);
343-
}
344-
}, DEFAULT_DELAY);
358+
}, DEBOUNCE_MS),
359+
);
345360
}
346361

347362
/**
@@ -366,7 +381,8 @@ module.exports = class NodeWatcher extends EventEmitter {
366381
function isIgnorableFileError(error: Error | {code: string}) {
367382
return (
368383
error.code === 'ENOENT' ||
369-
// Workaround Windows node issue #4337.
384+
// Workaround Windows EPERM on watched folder deletion.
385+
// https://github.com/nodejs/node-v0.x-archive/issues/4337
370386
(error.code === 'EPERM' && platform === 'win32')
371387
);
372388
}

packages/metro-file-map/src/watchers/__tests__/integration-test.js

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe.each(Object.keys(WATCHERS))(
104104
// Even though we write it before initialising watchers, OS-level
105105
// delays/debouncing(?) can mean the write is *sometimes* reported by
106106
// the watcher.
107-
ignored: /\.watchmanconfig/,
107+
ignored: /(\.watchmanconfig|ignored-)/,
108108
watchmanDeferStates: [],
109109
};
110110

@@ -267,6 +267,7 @@ describe.each(Object.keys(WATCHERS))(
267267
async () => {
268268
await mkdir(join(appRoot, 'subdir', 'subdir2'), {recursive: true});
269269
await Promise.all([
270+
writeFile(join(appRoot, 'subdir', 'ignored-file.js'), ''),
270271
writeFile(join(appRoot, 'subdir', 'deep.js'), ''),
271272
writeFile(join(appRoot, 'subdir', 'subdir2', 'deeper.js'), ''),
272273
]);
@@ -277,24 +278,9 @@ describe.each(Object.keys(WATCHERS))(
277278
[join('app', 'subdir', 'deep.js'), 'add'],
278279
[join('app', 'subdir', 'subdir2', 'deeper.js'), 'add'],
279280
],
280-
{
281-
// FIXME: NodeWatcher may report events multiple times as it
282-
// establishes watches on new directories and then crawls them
283-
// recursively, emitting all contents. When a directory is created
284-
// then immediately populated, the new contents may be seen by both
285-
// the crawl and the watch.
286-
rejectUnexpected: !(watcherInstance instanceof NodeWatcher),
287-
},
281+
{rejectUnexpected: true},
288282
);
289283

290-
// FIXME: Because NodeWatcher recursively watches new subtrees and
291-
// watch initialization is not instantaneous, we need to allow some
292-
// time for NodeWatcher to watch the new directories, othwerwise
293-
// deletion events may be missed.
294-
if (watcherInstance instanceof NodeWatcher) {
295-
await new Promise(resolve => setTimeout(resolve, 200));
296-
}
297-
298284
await allEvents(
299285
async () => {
300286
await rm(join(appRoot, 'subdir'), {recursive: true});

packages/metro-file-map/src/watchers/common.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ const walker = require('walker');
3131
/**
3232
* Constants
3333
*/
34-
export const DEFAULT_DELAY = 100;
3534
export const CHANGE_EVENT = 'change';
3635
export const DELETE_EVENT = 'delete';
3736
export const ADD_EVENT = 'add';

0 commit comments

Comments
 (0)