Skip to content

Commit 6fb75a1

Browse files
watch: detect files replaced via unlink and create
Signed-off-by: marcopiraccini <marco.piraccini@gmail.com>
1 parent 4383f67 commit 6fb75a1

2 files changed

Lines changed: 40 additions & 9 deletions

File tree

‎lib/internal/watch_mode/files_watcher.js‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ class FilesWatcher extends EventEmitter {
120120
watcher.on('change', (eventType, fileName) => {
121121
// `fileName` can be `null` if it cannot be determined. See
122122
// https://github.com/nodejs/node/pull/49891#issuecomment-1744673430.
123-
this.#onChange(recursive ? resolve(path, fileName ?? '') : path, eventType);
123+
// `path` is the watched directory (see `filterFile`), so resolve the
124+
// changed entry against it to get the absolute path of the trigger.
125+
this.#onChange(resolve(path, fileName ?? ''), eventType);
124126
});
125127
this.#watchers.set(path, { handle: watcher, recursive });
126128
if (recursive) {
@@ -133,9 +135,13 @@ class FilesWatcher extends EventEmitter {
133135
if (supportsRecursiveWatching) {
134136
this.watchPath(dirname(file), true, options);
135137
} else {
136-
// Having multiple FSWatcher's seems to be slower
137-
// than a single recursive FSWatcher
138-
this.watchPath(file, false, options);
138+
// Watch the parent directory non-recursively rather than the file
139+
// itself. A watch bound to the file's inode stops receiving events once
140+
// the file is replaced via unlink+create or rename (atomic saves, Docker
141+
// Compose watch, ...), so only the first replacement would be detected.
142+
// Watching the directory keeps working across replacements, and unrelated
143+
// siblings are discarded by the `filter` mode check in `#onChange`.
144+
this.watchPath(dirname(file), false, options);
139145
}
140146
this.#filteredFiles.add(file);
141147
if (owner) {

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

Lines changed: 30 additions & 5 deletions
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, appendFileSync } from 'node:fs';
9+
import { writeFileSync, mkdirSync, appendFileSync, rmSync } from 'node:fs';
1010
import { createInterface } from 'node:readline';
1111
import { setTimeout } from 'node:timers/promises';
1212
import { once } from 'node:events';
@@ -44,6 +44,19 @@ describe('watch mode file watcher', () => {
4444
});
4545
}
4646

47+
function replaceAndWaitForChanges(watcher, file) {
48+
return new Promise((resolve) => {
49+
const interval = setInterval(() => {
50+
rmSync(file, { force: true });
51+
writeFileSync(file, `replace ${counter++}`);
52+
}, 100);
53+
watcher.once('changed', () => {
54+
clearInterval(interval);
55+
resolve();
56+
});
57+
});
58+
}
59+
4760
it('should watch changed files', async () => {
4861
const file = tmpdir.resolve('file1');
4962
writeFileSync(file, 'written');
@@ -52,6 +65,19 @@ describe('watch mode file watcher', () => {
5265
assert.strictEqual(changesCount, 1);
5366
});
5467

68+
it('should keep detecting files replaced via unlink and create', async () => {
69+
// Regression test for https://github.com/nodejs/node/issues/51621: a watch
70+
// bound to the file inode stops firing after the first replacement, so the
71+
// second `replaceAndWaitForChanges` call would hang on the buggy behavior.
72+
const file = tmpdir.resolve('replaced.js');
73+
writeFileSync(file, 'written');
74+
watcher.filterFile(file);
75+
await replaceAndWaitForChanges(watcher, file);
76+
await replaceAndWaitForChanges(watcher, file);
77+
await replaceAndWaitForChanges(watcher, file);
78+
assert.ok(changesCount >= 3, `expected at least 3 changes, got ${changesCount}`);
79+
});
80+
5581
it('should watch changed files with same prefix path string', async () => {
5682
mkdirSync(tmpdir.resolve('subdir'));
5783
mkdirSync(tmpdir.resolve('sub'));
@@ -204,10 +230,9 @@ describe('watch mode file watcher', () => {
204230
const child = spawn(process.execPath, [file], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'], encoding: 'utf8' });
205231
watcher.watchChildProcessModules(child);
206232
await once(child, 'exit');
207-
let expected = [file, tmpdir.resolve('file')];
208-
if (supportsRecursiveWatching) {
209-
expected = expected.map((file) => path.dirname(file));
210-
}
233+
// The parent directory is watched on every platform so that files replaced
234+
// via unlink+create or rename are still detected.
235+
const expected = [file, tmpdir.resolve('file')].map((file) => path.dirname(file));
211236
assert.deepStrictEqual(watcher.watchedPaths, expected);
212237
});
213238
});

0 commit comments

Comments
 (0)