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: {