Skip to content

Commit 95a9603

Browse files
committed
watch: ensure watch mode detects deleted and re-added files
1 parent f202322 commit 95a9603

File tree

3 files changed

+129
-9
lines changed

3 files changed

+129
-9
lines changed

lib/internal/watch_mode/files_watcher.js

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ const { TIMEOUT_MAX } = require('internal/timers');
1414

1515
const EventEmitter = require('events');
1616
const { addAbortListener } = require('internal/events/abort_listener');
17-
const { watch } = require('fs');
17+
const { watch, existsSync } = require('fs');
1818
const { fileURLToPath } = require('internal/url');
1919
const { resolve, dirname } = require('path');
20-
const { setTimeout } = require('timers');
20+
const { setTimeout, clearTimeout, setInterval, clearInterval } = require('timers');
2121

2222
const supportsRecursiveWatching = process.platform === 'win32' ||
2323
process.platform === 'darwin';
@@ -29,16 +29,26 @@ class FilesWatcher extends EventEmitter {
2929
#depencencyOwners = new SafeMap();
3030
#ownerDependencies = new SafeMap();
3131
#debounce;
32+
#renameInterval;
33+
#renameTimeout;
3234
#mode;
3335
#signal;
3436

35-
constructor({ debounce = 200, mode = 'filter', signal } = kEmptyObject) {
37+
constructor({
38+
debounce = 200,
39+
mode = 'filter',
40+
renameInterval = 1000,
41+
renameTimeout = 60_000,
42+
signal,
43+
} = kEmptyObject) {
3644
super({ __proto__: null, captureRejections: true });
3745

3846
validateNumber(debounce, 'options.debounce', 0, TIMEOUT_MAX);
3947
validateOneOf(mode, 'options.mode', ['filter', 'all']);
4048
this.#debounce = debounce;
4149
this.#mode = mode;
50+
this.#renameInterval = renameInterval;
51+
this.#renameTimeout = renameTimeout;
4252
this.#signal = signal;
4353

4454
if (signal) {
@@ -74,7 +84,10 @@ class FilesWatcher extends EventEmitter {
7484
watcher.handle.close();
7585
}
7686

77-
#onChange(trigger) {
87+
#onChange(eventType, trigger, recursive) {
88+
if (eventType === 'rename' && !recursive) {
89+
return this.#rewatch(trigger);
90+
}
7891
if (this.#debouncing.has(trigger)) {
7992
return;
8093
}
@@ -89,6 +102,39 @@ class FilesWatcher extends EventEmitter {
89102
}, this.#debounce).unref();
90103
}
91104

105+
// When a file is removed, wait for it to be re-added.
106+
// Often this re-add is immediate - some editors (e.g., gedit) and some docker mount modes do this.
107+
#rewatch(path) {
108+
if (this.#isPathWatched(path)) {
109+
this.#unwatch(this.#watchers.get(path));
110+
this.#watchers.delete(path);
111+
if (existsSync(path)) {
112+
this.watchPath(path, false);
113+
// This might be redundant. If the file was re-added due to a save event, we will probably see change -> rename.
114+
// However, in certain situations it's entirely possible for the content to have changed after the rename
115+
// In these situations we'd miss the change after the rename event
116+
this.#onChange('change', path, false);
117+
return;
118+
}
119+
let timeout;
120+
121+
// Wait for the file to exist - check every `renameInterval` ms
122+
const interval = setInterval(async () => {
123+
if (existsSync(path)) {
124+
clearInterval(interval);
125+
clearTimeout(timeout);
126+
this.watchPath(path, false);
127+
this.#onChange('change', path, false);
128+
}
129+
}, this.#renameInterval).unref();
130+
131+
// Don't wait forever - after `renameTimeout` ms, stop trying
132+
timeout = setTimeout(() => {
133+
clearInterval(interval);
134+
}, this.#renameTimeout).unref();
135+
}
136+
}
137+
92138
get watchedPaths() {
93139
return [...this.#watchers.keys()];
94140
}
@@ -101,7 +147,7 @@ class FilesWatcher extends EventEmitter {
101147
watcher.on('change', (eventType, fileName) => {
102148
// `fileName` can be `null` if it cannot be determined. See
103149
// https://github.com/nodejs/node/pull/49891#issuecomment-1744673430.
104-
this.#onChange(recursive ? resolve(path, fileName ?? '') : path);
150+
this.#onChange(eventType, recursive ? resolve(path, fileName) : path, recursive);
105151
});
106152
this.#watchers.set(path, { handle: watcher, recursive });
107153
if (recursive) {

test/parallel/test-watch-mode-files_watcher.mjs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import path from 'node:path';
66
import assert from 'node:assert';
77
import process from 'node:process';
88
import { describe, it, beforeEach, afterEach } from 'node:test';
9-
import { writeFileSync, mkdirSync } from 'node:fs';
9+
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
1010
import { setTimeout } from 'node:timers/promises';
1111
import { once } from 'node:events';
1212
import { spawn } from 'node:child_process';
@@ -159,4 +159,43 @@ describe('watch mode file watcher', () => {
159159
}
160160
assert.deepStrictEqual(watcher.watchedPaths, expected);
161161
});
162+
163+
it('should capture changes of a renamed file when re-written within the timeout', async () => {
164+
watcher = new FilesWatcher({ debounce: 1, renameInterval: 10, renameTimeout: 20, mode: 'all' });
165+
watcher.on('changed', () => changesCount++);
166+
167+
const file = tmpdir.resolve('file5');
168+
writeFileSync(file, 'changed');
169+
watcher.watchPath(file, false);
170+
171+
let changed = once(watcher, 'changed');
172+
rmSync(file);
173+
await changed;
174+
changed = once(watcher, 'changed');
175+
writeFileSync(file, 'changed1');
176+
await changed;
177+
changed = once(watcher, 'changed');
178+
writeFileSync(file, 'changed1');
179+
await changed;
180+
assert.strictEqual(changesCount, 3);
181+
});
182+
183+
it('should NOT capture changes of a renamed file when re-written after the timeout', async () => {
184+
watcher = new FilesWatcher({ debounce: 1, renameInterval: 20, renameTimeout: 10, mode: 'all' });
185+
watcher.on('changed', () => changesCount++);
186+
187+
const file = tmpdir.resolve('file5');
188+
writeFileSync(file, 'changed');
189+
watcher.watchPath(file, false);
190+
191+
const changed = once(watcher, 'changed');
192+
193+
rmSync(file);
194+
await changed;
195+
writeFileSync(file, 'changed1');
196+
await setTimeout(5);
197+
writeFileSync(file, 'changed2');
198+
await setTimeout(5);
199+
assert.strictEqual(changesCount, 1);
200+
});
162201
});

test/sequential/test-watch-mode.mjs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from 'node:path';
55
import { execPath } from 'node:process';
66
import { describe, it } from 'node:test';
77
import { spawn } from 'node:child_process';
8-
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
8+
import { writeFileSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
99
import { inspect } from 'node:util';
1010
import { pathToFileURL } from 'node:url';
1111
import { createInterface } from 'node:readline';
@@ -37,7 +37,8 @@ async function runWriteSucceed({
3737
completed = 'Completed running',
3838
restarts = 2,
3939
options = {},
40-
shouldFail = false
40+
shouldFail = false,
41+
restartFn = restart
4142
}) {
4243
args.unshift('--no-warnings');
4344
if (watchFlag !== null) args.unshift(watchFlag);
@@ -63,7 +64,7 @@ async function runWriteSucceed({
6364
break;
6465
}
6566
if (completes === 1) {
66-
cancelRestarts = restart(watchedFile);
67+
cancelRestarts = restartFn(watchedFile);
6768
}
6869
}
6970

@@ -574,4 +575,38 @@ console.log(values.random);
574575
`Completed running ${inspect(file)}`,
575576
]);
576577
});
578+
579+
it('should watch changes to removed and readded files', async () => {
580+
const file = createTmpFile();
581+
let restartCount = 0;
582+
const { stderr, stdout } = await runWriteSucceed({
583+
file,
584+
watchedFile: file,
585+
watchFlag: '--watch=true',
586+
options: {
587+
timeout: 10000
588+
},
589+
restarts: 3,
590+
restartFn(fileName) {
591+
const content = readFileSync(fileName);
592+
if (restartCount === 0) {
593+
rmSync(fileName);
594+
}
595+
restartCount++;
596+
return restart(fileName, content);
597+
}
598+
});
599+
600+
assert.strictEqual(stderr, '');
601+
assert.deepStrictEqual(stdout, [
602+
'running',
603+
`Completed running ${inspect(file)}`,
604+
`Restarting ${inspect(file)}`,
605+
'running',
606+
`Completed running ${inspect(file)}`,
607+
`Restarting ${inspect(file)}`,
608+
'running',
609+
`Completed running ${inspect(file)}`,
610+
]);
611+
});
577612
});

0 commit comments

Comments
 (0)