Skip to content

Commit 603c522

Browse files
committed
watch: cancel pending restart on shutdown
Cancel any pending FilesWatcher debounce timer when watch mode clears its watchers. This prevents a queued change event from restarting the watched process after shutdown has started. Keep the watched child exit handling attached for the lifetime of the child so overlapping restart and shutdown paths do not remove each other's exit listeners. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.5
1 parent 46e17c7 commit 603c522

4 files changed

Lines changed: 107 additions & 7 deletions

File tree

lib/internal/main/watch_mode.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,13 @@ ArrayPrototypeForEach(kWatchedPaths, (p) => watcher.watchPath(p));
8383

8484
let graceTimer;
8585
let child;
86+
let childExitPromise;
8687
let exited;
88+
let stopping;
8789

8890
function start() {
8991
exited = false;
92+
stopping = false;
9093
const stdio = kShouldFilterModules ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit';
9194
child = spawn(process.execPath, argsWithoutWatchOptions, {
9295
stdio,
@@ -103,29 +106,36 @@ function start() {
103106
ArrayPrototypeForEach(kOptionalEnvFiles,
104107
(file) => watcher.filterFile(resolve(file), undefined, { allowMissing: true }));
105108
}
106-
child.once('exit', (code) => {
109+
childExitPromise = once(child, 'exit').then(({ 0: code }) => {
107110
exited = true;
111+
if (stopping) {
112+
return code;
113+
}
108114
const waitingForChanges = 'Waiting for file changes before restarting...';
109115
if (code === 0) {
110116
process.stdout.write(`${blue}Completed running ${kCommandStr}. ${waitingForChanges}${white}\n`);
111117
} else {
112118
process.stdout.write(`${red}Failed running ${kCommandStr}. ${waitingForChanges}${white}\n`);
113119
}
120+
return code;
114121
});
115122
return child;
116123
}
117124

118125
async function killAndWait(signal = kKillSignal, force = false) {
119-
child?.removeAllListeners();
120-
if (!child) {
126+
const processToKill = child;
127+
const onExit = childExitPromise;
128+
if (!processToKill) {
121129
return;
122130
}
123-
if ((child.killed || exited) && !force) {
131+
if ((processToKill.killed || exited) && !force) {
124132
return;
125133
}
126-
const onExit = once(child, 'exit');
127-
child.kill(signal);
128-
const { 0: exitCode } = await onExit;
134+
stopping = true;
135+
if (!exited && processToKill.exitCode === null && processToKill.signalCode === null) {
136+
processToKill.kill(signal);
137+
}
138+
const exitCode = await onExit;
129139
return exitCode;
130140
}
131141

lib/internal/watch_mode/files_watcher.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ class FilesWatcher extends EventEmitter {
204204
this.#filteredFiles.clear();
205205
}
206206
clear() {
207+
clearTimeout(this.#debounceTimer);
208+
this.#debounceTimer = null;
209+
this.#debounceOwners.clear();
207210
this.#watchers.forEach(this.#unwatch);
208211
this.#watchers.clear();
209212
this.#filteredFiles.clear();
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Flags: --expose-internals
2+
import * as common from '../common/index.mjs';
3+
import tmpdir from '../common/tmpdir.js';
4+
import assert from 'node:assert';
5+
import { writeFileSync } from 'node:fs';
6+
import { createRequire } from 'node:module';
7+
import { setTimeout as sleep } from 'node:timers/promises';
8+
9+
if (common.isIBMi)
10+
common.skip('IBMi does not support `fs.watch()`');
11+
12+
const require = createRequire(import.meta.url);
13+
const timers = require('node:timers');
14+
const originalSetTimeout = timers.setTimeout;
15+
const originalClearTimeout = timers.clearTimeout;
16+
const { promise, resolve } = Promise.withResolvers();
17+
let debounceTimer;
18+
let debounceTimerCleared = false;
19+
20+
timers.setTimeout = function(fn, delay, ...args) {
21+
const timer = originalSetTimeout(fn, delay, ...args);
22+
if (delay === 1000) {
23+
debounceTimer = timer;
24+
debounceTimer.ref();
25+
resolve();
26+
}
27+
return timer;
28+
};
29+
30+
timers.clearTimeout = function(timer) {
31+
if (timer === debounceTimer) {
32+
debounceTimerCleared = true;
33+
}
34+
return originalClearTimeout(timer);
35+
};
36+
37+
try {
38+
const { FilesWatcher } = require('internal/watch_mode/files_watcher');
39+
40+
tmpdir.refresh();
41+
const file = tmpdir.resolve('watcher-clear.js');
42+
writeFileSync(file, '0');
43+
44+
const watcher = new FilesWatcher({ debounce: 1000, mode: 'all' });
45+
watcher.on('changed', common.mustNotCall());
46+
watcher.watchPath(file, false);
47+
48+
const interval = setInterval(() => writeFileSync(file, `${Date.now()}`), 50);
49+
await promise;
50+
clearInterval(interval);
51+
52+
watcher.clear();
53+
assert.strictEqual(debounceTimerCleared, true);
54+
await sleep(1100);
55+
} finally {
56+
timers.setTimeout = originalSetTimeout;
57+
timers.clearTimeout = originalClearTimeout;
58+
}

test/sequential/test-watch-mode.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,35 @@ async function failWriteSucceed({ file, watchedFile }) {
171171
tmpdir.refresh();
172172

173173
describe('watch mode', { concurrency: !process.env.TEST_PARALLEL, timeout: 60_000 }, () => {
174+
it('should exit when terminated after the watched process has completed', async () => {
175+
const file = createTmpFile();
176+
const child = spawn(execPath, ['--watch', '--no-warnings', file], {
177+
encoding: 'utf8',
178+
stdio: 'pipe',
179+
});
180+
181+
for await (const line of createInterface({ input: child.stdout })) {
182+
if (line.includes('Completed running')) {
183+
break;
184+
}
185+
}
186+
187+
child.kill();
188+
const timedOut = Promise.withResolvers();
189+
const timer = setTimeout(() => {
190+
timedOut.reject(new Error('Timed out waiting for watch mode to exit'));
191+
if (child.exitCode === null && child.signalCode === null) {
192+
child.kill('SIGKILL');
193+
}
194+
}, common.platformTimeout(5000));
195+
timer.unref();
196+
try {
197+
await Promise.race([once(child, 'exit'), timedOut.promise]);
198+
} finally {
199+
clearTimeout(timer);
200+
}
201+
});
202+
174203
it('should watch changes to a file', async () => {
175204
const file = createTmpFile();
176205
const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, watchFlag: '--watch=true', options: {

0 commit comments

Comments
 (0)