From 7f3594d78395bd818f51f5880b3f3723ae7589ab Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sun, 17 May 2026 08:07:08 -0700 Subject: [PATCH] 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 --- lib/internal/main/watch_mode.js | 24 +++++--- lib/internal/watch_mode/files_watcher.js | 3 + .../test-watch-mode-files_watcher-clear.mjs | 58 +++++++++++++++++++ test/sequential/test-watch-mode.mjs | 29 ++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 test/parallel/test-watch-mode-files_watcher-clear.mjs diff --git a/lib/internal/main/watch_mode.js b/lib/internal/main/watch_mode.js index 63d1b51c815155..ba441a960f07d1 100644 --- a/lib/internal/main/watch_mode.js +++ b/lib/internal/main/watch_mode.js @@ -83,10 +83,13 @@ ArrayPrototypeForEach(kWatchedPaths, (p) => watcher.watchPath(p)); let graceTimer; let child; +let childExitPromise; let exited; +let stopping; function start() { exited = false; + stopping = false; const stdio = kShouldFilterModules ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit'; child = spawn(process.execPath, argsWithoutWatchOptions, { stdio, @@ -103,29 +106,36 @@ function start() { ArrayPrototypeForEach(kOptionalEnvFiles, (file) => watcher.filterFile(resolve(file), undefined, { allowMissing: true })); } - child.once('exit', (code) => { + childExitPromise = once(child, 'exit').then(({ 0: code }) => { exited = true; + if (stopping) { + return code; + } const waitingForChanges = 'Waiting for file changes before restarting...'; if (code === 0) { process.stdout.write(`${blue}Completed running ${kCommandStr}. ${waitingForChanges}${white}\n`); } else { process.stdout.write(`${red}Failed running ${kCommandStr}. ${waitingForChanges}${white}\n`); } + return code; }); return child; } async function killAndWait(signal = kKillSignal, force = false) { - child?.removeAllListeners(); - if (!child) { + const processToKill = child; + const onExit = childExitPromise; + if (!processToKill) { return; } - if ((child.killed || exited) && !force) { + if ((processToKill.killed || exited) && !force) { return; } - const onExit = once(child, 'exit'); - child.kill(signal); - const { 0: exitCode } = await onExit; + stopping = true; + if (!exited && processToKill.exitCode === null && processToKill.signalCode === null) { + processToKill.kill(signal); + } + const exitCode = await onExit; return exitCode; } diff --git a/lib/internal/watch_mode/files_watcher.js b/lib/internal/watch_mode/files_watcher.js index 33b34eb98b45e7..c94775b3e6e4bc 100644 --- a/lib/internal/watch_mode/files_watcher.js +++ b/lib/internal/watch_mode/files_watcher.js @@ -204,6 +204,9 @@ class FilesWatcher extends EventEmitter { this.#filteredFiles.clear(); } clear() { + clearTimeout(this.#debounceTimer); + this.#debounceTimer = null; + this.#debounceOwners.clear(); this.#watchers.forEach(this.#unwatch); this.#watchers.clear(); this.#filteredFiles.clear(); diff --git a/test/parallel/test-watch-mode-files_watcher-clear.mjs b/test/parallel/test-watch-mode-files_watcher-clear.mjs new file mode 100644 index 00000000000000..a47d0578ff8952 --- /dev/null +++ b/test/parallel/test-watch-mode-files_watcher-clear.mjs @@ -0,0 +1,58 @@ +// Flags: --expose-internals +import * as common from '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import assert from 'node:assert'; +import { writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { setTimeout as sleep } from 'node:timers/promises'; + +if (common.isIBMi) + common.skip('IBMi does not support `fs.watch()`'); + +const require = createRequire(import.meta.url); +const timers = require('node:timers'); +const originalSetTimeout = timers.setTimeout; +const originalClearTimeout = timers.clearTimeout; +const { promise, resolve } = Promise.withResolvers(); +let debounceTimer; +let debounceTimerCleared = false; + +timers.setTimeout = function(fn, delay, ...args) { + const timer = originalSetTimeout(fn, delay, ...args); + if (delay === 1000) { + debounceTimer = timer; + debounceTimer.ref(); + resolve(); + } + return timer; +}; + +timers.clearTimeout = function(timer) { + if (timer === debounceTimer) { + debounceTimerCleared = true; + } + return originalClearTimeout(timer); +}; + +try { + const { FilesWatcher } = require('internal/watch_mode/files_watcher'); + + tmpdir.refresh(); + const file = tmpdir.resolve('watcher-clear.js'); + writeFileSync(file, '0'); + + const watcher = new FilesWatcher({ debounce: 1000, mode: 'all' }); + watcher.on('changed', common.mustNotCall()); + watcher.watchPath(file, false); + + const interval = setInterval(() => writeFileSync(file, `${Date.now()}`), 50); + await promise; + clearInterval(interval); + + watcher.clear(); + assert.strictEqual(debounceTimerCleared, true); + await sleep(1100); +} finally { + timers.setTimeout = originalSetTimeout; + timers.clearTimeout = originalClearTimeout; +} diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs index 707210a021f944..ef3c0214db1187 100644 --- a/test/sequential/test-watch-mode.mjs +++ b/test/sequential/test-watch-mode.mjs @@ -171,6 +171,35 @@ async function failWriteSucceed({ file, watchedFile }) { tmpdir.refresh(); describe('watch mode', { concurrency: !process.env.TEST_PARALLEL, timeout: 60_000 }, () => { + it('should exit when terminated after the watched process has completed', async () => { + const file = createTmpFile(); + const child = spawn(execPath, ['--watch', '--no-warnings', file], { + encoding: 'utf8', + stdio: 'pipe', + }); + + for await (const line of createInterface({ input: child.stdout })) { + if (line.includes('Completed running')) { + break; + } + } + + child.kill(); + const timedOut = Promise.withResolvers(); + const timer = setTimeout(() => { + timedOut.reject(new Error('Timed out waiting for watch mode to exit')); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + }, common.platformTimeout(5000)); + timer.unref(); + try { + await Promise.race([once(child, 'exit'), timedOut.promise]); + } finally { + clearTimeout(timer); + } + }); + it('should watch changes to a file', async () => { const file = createTmpFile(); const { stderr, stdout } = await runWriteSucceed({ file, watchedFile: file, watchFlag: '--watch=true', options: {