From 59a201b5d88376121fec5fce66bc577ef4990638 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Wed, 11 Nov 2026 14:37:01 +0900 Subject: [PATCH 01/14] fix(prompts): end by SIGINT when Ctrl+C interrupts a spinner clack's block() calls process.exit(0) on Ctrl+C and Escape, so an interrupted command reported success. Replace it with a local input block that stops the spinner and sends SIGINT to the foreground process group, as the terminal does outside raw mode. Windows has no signals, so the process exits with 130 there. Escape and other keys are ignored, as the Rust progress row does. A key pressed just before the spinner stops no longer leaves a listener on stdin. Refs #3429 --- packages/prompts/src/__tests__/output.spec.ts | 64 +++++++++++ .../src/__tests__/spinner-signals.spec.ts | 108 ++++++++++++++++++ packages/prompts/src/spinner.ts | 65 ++++++++++- 3 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 packages/prompts/src/__tests__/spinner-signals.spec.ts diff --git a/packages/prompts/src/__tests__/output.spec.ts b/packages/prompts/src/__tests__/output.spec.ts index ebba20dbe5..2681b46e99 100644 --- a/packages/prompts/src/__tests__/output.spec.ts +++ b/packages/prompts/src/__tests__/output.spec.ts @@ -30,6 +30,21 @@ function capture(columns = 52, isTTY = false) { return { output, raw: () => content, text: () => stripVTControlCharacters(content) }; } +function press(name: string, sequence: string, ctrl = false) { + process.stdin.emit('keypress', sequence, { name, sequence, ctrl }); +} + +/** Starts a spinner that blocks keys, with process termination stubbed out. */ +function spinInTerminal() { + vi.stubEnv('CI', ''); + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never); + const c = capture(52, true); + const s = spinner({ output: c.output }); + s.start('Building'); + return { c, s, kill, exit }; +} + function expectNoTerminalControls(output: string) { // Forced colors are valid in CI and pipes; cursor movement and erasure are not. // oxlint-disable-next-line no-control-regex @@ -42,6 +57,7 @@ afterEach(() => { updateSettings({ withGuide: false }); vi.useRealTimers(); vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); describe('output identity', () => { @@ -196,4 +212,52 @@ describe('spinner', () => { expect(c.text()).toContain('■ Canceled'); expect(process.listenerCount('SIGINT')).toBe(before); }); + + describe('keys while spinning in a terminal', () => { + const platform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }); + }); + + it('interrupts the foreground process group on Ctrl+C instead of exiting with 0', () => { + const { c, s, kill, exit } = spinInTerminal(); + press('c', '\x03', true); + expect(s.isCancelled).toBe(true); + expect(c.text()).toContain('■ Canceled'); + expect(kill).toHaveBeenCalledWith(0, 'SIGINT'); + expect(exit).not.toHaveBeenCalled(); + }); + + it('exits with 130 on Ctrl+C on Windows, which has no signals', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const { c, kill, exit } = spinInTerminal(); + press('c', '\x03', true); + expect(c.text()).toContain('■ Canceled'); + expect(exit).toHaveBeenCalledWith(130); + expect(kill).not.toHaveBeenCalled(); + }); + + it('ignores Escape and keeps waiting for Ctrl+C', async () => { + const { c, s, kill, exit } = spinInTerminal(); + press('escape', '\x1b'); + expect(s.isCancelled).toBe(false); + expect(c.text()).not.toContain('Canceled'); + expect(kill).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + // The next key is awaited once the echoed one is erased. + await new Promise((resolve) => setImmediate(resolve)); + press('c', '\x03', true); + expect(kill).toHaveBeenCalledWith(0, 'SIGINT'); + }); + + it('stops listening for keys once the spinner stops', async () => { + const { s, kill } = spinInTerminal(); + press('a', 'a'); + s.stop('Built'); + await new Promise((resolve) => setImmediate(resolve)); + press('c', '\x03', true); + expect(kill).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts new file mode 100644 index 0000000000..5fa86afcaf --- /dev/null +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -0,0 +1,108 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { build } from 'tsdown'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +function isAlive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// Windows has no signals or process groups; output.spec.ts covers its exit code. +describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { + let directory: string; + let prelude: string; + + beforeAll(async () => { + directory = mkdtempSync(join(tmpdir(), 'vp-spinner-signals-')); + // Build current source rather than relying on potentially stale package output. + await build({ + config: false, + entry: [fileURLToPath(new URL('../spinner.ts', import.meta.url))], + outDir: directory, + outExtensions: () => ({ js: '.mjs' }), + deps: { alwaysBundle: [/.*/], onlyBundle: false }, + dts: false, + logLevel: 'silent', + }); + // Synchronous writes, so output is not lost when a signal ends the process. + prelude = ` + import { writeSync } from 'node:fs'; + import { Writable } from 'node:stream'; + import { spinner } from ${JSON.stringify(pathToFileURL(join(directory, 'spinner.mjs')).href)}; + const output = (isTTY) => Object.assign( + new Writable({ write(chunk, _, done) { writeSync(1, chunk); done(); } }), + { isTTY, columns: 80 }, + ); + const keepAlive = setInterval(() => {}, 1000); + `; + }); + + afterAll(() => { + if (directory) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + /** + * Runs `script` in its own process group, because Ctrl+C signals the whole + * group. `act` runs once the script sends its first message. + */ + async function run(script: string, act: (child: ChildProcess, message: unknown) => void) { + const child = spawn(process.execPath, ['--input-type=module', '--eval', prelude + script], { + detached: true, + env: { ...process.env, CI: '' }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + timeout: 5000, + killSignal: 'SIGKILL', + }); + let stdout = ''; + let stderr = ''; + child.stdout!.on('data', (data) => { + stdout += data; + }); + child.stderr!.on('data', (data) => { + stderr += data; + }); + child.once('message', (message) => act(child, message)); + const [code, signal] = await once(child, 'close'); + return { code, signal, stdout, stderr }; + } + + it('ends the process group by SIGINT on Ctrl+C', async () => { + let worker = 0; + try { + const result = await run( + ` + import { spawn } from 'node:child_process'; + const worker = spawn(process.execPath, ['--eval', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + spinner({ output: output(true) }).start('Working'); + process.send(worker.pid); + `, + (child, pid) => { + worker = pid as number; + child.stdin!.write('\x03'); + }, + ); + expect(result).toMatchObject({ code: null, signal: 'SIGINT', stderr: '' }); + expect(result.stdout).toContain('Canceled'); + // A running package manager must not outlive the interrupted command. + await expect.poll(() => isAlive(worker), { timeout: 2000 }).toBe(false); + } finally { + if (worker && isAlive(worker)) { + process.kill(worker, 'SIGKILL'); + } + } + }); +}); diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index a9915488f2..22aab067ee 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -1,4 +1,7 @@ -import { block, getColumns, isAccessible, settings } from '@clack/core'; +import readline from 'node:readline'; +import type { Writable } from 'node:stream'; + +import { getColumns, isAccessible, settings } from '@clack/core'; import { wrapAnsi } from 'fast-wrap-ansi'; import color from 'picocolors'; import { cursor, erase } from 'sisteransi'; @@ -51,6 +54,58 @@ const formatTimer = (durationMs: number): string => { return color.gray(min > 0 ? `(${min}m ${secs}s)` : `(${secs}s)`); }; +// Windows has no signals, so exit there with the status a shell reports for one. +const endBySignal = (signal: 'SIGINT' | 'SIGTERM', pid: number): void => { + if (process.platform === 'win32') { + process.exit(signal === 'SIGINT' ? 130 : 143); + } else { + process.kill(pid, signal); + } +}; + +// clack's block() calls process.exit(0) on Ctrl+C and Escape, which reports an +// interrupted command as successful. This copy reports Ctrl+C to the caller and +// ignores every other key, as the Rust progress row does. +const blockInput = (output: Writable, onInterrupt: () => void): (() => void) => { + const input = process.stdin; + const rl = readline.createInterface({ input, output, prompt: '', tabSize: 1 }); + readline.emitKeypressEvents(input, rl); + if (input.isTTY) { + input.setRawMode(true); + } + let released = false; + const onKeypress = (_char: string | undefined, { name, sequence }: readline.Key): void => { + if (sequence === '\x03') { + onInterrupt(); + return; + } + // Erase the echoed key, then wait for the next one. + const [dx, dy] = name === 'return' ? [0, -1] : [-1, 0]; + readline.moveCursor(output, dx, dy, () => { + readline.clearLine(output, 1, () => { + // The spinner can stop before these writes finish. + if (!released) { + input.once('keypress', onKeypress); + } + }); + }); + }; + output.write(cursor.hide); + input.once('keypress', onKeypress); + return () => { + released = true; + input.off('keypress', onKeypress); + output.write(cursor.show); + // Windows keeps raw mode: https://github.com/bombshell-dev/clack/issues/176 + if (input.isTTY && process.platform !== 'win32') { + input.setRawMode(false); + } + // @ts-expect-error https://github.com/nodejs/node/issues/31762#issuecomment-1441223907 + rl.terminal = false; + rl.close(); + }; +}; + export const spinner = ({ indicator = 'dots', onCancel, @@ -100,6 +155,12 @@ export const spinner = ({ const errorEventHandler = () => handleExit(2); const signalEventHandler = () => handleExit(1); + // Raw mode turns Ctrl+C into a key press. Send SIGINT to the foreground process + // group, as the terminal does outside raw mode, so that child processes stop too. + const interrupt = () => { + handleExit(1); + endBySignal('SIGINT', 0); + }; const registerHooks = () => { // Reference: https://nodejs.org/api/process.html#event-uncaughtexception @@ -151,7 +212,7 @@ export const spinner = ({ const startLoop = (): void => { isSpinnerActive = true; - unblock = isCI ? () => {} : block({ output }); + unblock = isCI ? () => {} : blockInput(output, interrupt); _origin = performance.now(); _prevMessage = undefined; if (hasGuide) { From 19eee2b1a48199c637bdd90b37b3b6fcd77adcce Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Wed, 11 Nov 2026 14:40:09 +0900 Subject: [PATCH 02/14] fix(prompts): end by the received signal when only a spinner owns it An active spinner listened for SIGINT and SIGTERM, stopped itself, and returned, so the command kept running after a termination request. Raise the signal again after stopping, but only when no other listener owns it: none was registered before the spinner started and none was added while it spun. An emit without the signal name only stops the spinner. Windows exits with the matching status instead. Closes #3429 --- .../src/__tests__/spinner-signals.spec.ts | 69 +++++++++++++++++++ packages/prompts/src/spinner.ts | 48 +++++++++++-- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index 5fa86afcaf..ccafa0109e 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -105,4 +105,73 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { } } }); + + describe.each(['SIGINT', 'SIGTERM'] as const)('%s', (signal) => { + it.each([1, 2])('ends the process by the signal with %i active spinners', async (count) => { + const result = await run( + ` + for (let i = 0; i < ${count}; i++) { + spinner({ output: output(false) }).start('Working'); + } + process.send('ready'); + `, + (child) => child.kill(signal), + ); + expect(result).toMatchObject({ code: null, signal, stderr: '' }); + expect(result.stdout).toContain('Canceled'); + }); + + // The shutdown exits with 42 when the signal reaches it twice, and a signal + // raised again during its cleanup ends the process before it reports. + it.each([ + ['a listener registered before the spinner', 'process.on(signal, shutdown); start();'], + ['a one-shot listener registered before it', 'process.once(signal, shutdown); start();'], + [ + 'a one-shot listener prepended while spinning', + 'start(); process.prependOnceListener(signal, shutdown);', + ], + ])('leaves the shutdown to %s', async (_, setup) => { + const result = await run( + ` + import { setTimeout as delay } from 'node:timers/promises'; + const signal = ${JSON.stringify(signal)}; + let active; + let calls = 0; + const start = () => { + active = spinner({ output: output(false) }); + active.start('Working'); + }; + const shutdown = async () => { + if (++calls > 1) process.exit(42); + await delay(50); + writeSync(1, 'cleanup complete, cancelled=' + active.isCancelled + '\\n'); + process.exit(0); + }; + ${setup} + process.send('ready'); + `, + (child) => child.kill(signal), + ); + expect(result).toMatchObject({ code: 0, signal: null, stderr: '' }); + expect(result.stdout).toContain('cleanup complete, cancelled=true'); + }); + }); + + it('only stops the spinner on an emit without the signal name', async () => { + const result = await run( + ` + const active = spinner({ output: output(false) }); + active.start('Working'); + process.emit('SIGINT'); + setTimeout(() => { + writeSync(1, 'still alive, cancelled=' + active.isCancelled + '\\n'); + process.exit(0); + }, 50); + process.send('ready'); + `, + () => {}, + ); + expect(result).toMatchObject({ code: 0, signal: null, stderr: '' }); + expect(result.stdout).toContain('still alive, cancelled=true'); + }); }); diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 22aab067ee..129bdfeb3f 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -54,8 +54,14 @@ const formatTimer = (durationMs: number): string => { return color.gray(min > 0 ? `(${min}m ${secs}s)` : `(${secs}s)`); }; +const PROCESS_SIGNALS = ['SIGINT', 'SIGTERM'] as const; +type ProcessSignal = (typeof PROCESS_SIGNALS)[number]; + +// Signal listeners of active spinners, which are not another owner of a signal. +const spinnerSignalListeners = new Set(); + // Windows has no signals, so exit there with the status a shell reports for one. -const endBySignal = (signal: 'SIGINT' | 'SIGTERM', pid: number): void => { +const endBySignal = (signal: ProcessSignal, pid: number): void => { if (process.platform === 'win32') { process.exit(signal === 'SIGINT' ? 130 : 143); } else { @@ -155,6 +161,28 @@ export const spinner = ({ const errorEventHandler = () => handleExit(2); const signalEventHandler = () => handleExit(1); + // Events another listener handles: one registered before this spinner started + // or added while it spins. For a signal, that owner decides how the process ends. + const ownedByOthers = new Set(); + const trackListener = (event: string | symbol, listener: unknown) => { + if (!spinnerSignalListeners.has(listener)) { + ownedByOthers.add(event); + } + }; + // A listener replaces the default action, so a spinner that is the only owner + // raises the signal again once its hooks are cleared. The process then ends + // with the signal status, as the Rust progress row does. An emit without the + // signal name did not come from the system and only stops the spinner. + const processSignalHandlers = PROCESS_SIGNALS.map((signal) => { + const handler = (received?: NodeJS.Signals) => { + const raise = received === signal && !ownedByOthers.has(signal); + handleExit(1); + if (raise) { + endBySignal(signal, process.pid); + } + }; + return [signal, handler] as const; + }); // Raw mode turns Ctrl+C into a key press. Send SIGINT to the foreground process // group, as the terminal does outside raw mode, so that child processes stop too. const interrupt = () => { @@ -168,9 +196,16 @@ export const spinner = ({ // Reference: https://nodejs.org/api/process.html#event-unhandledrejection process.on('unhandledRejection', errorEventHandler); // Reference Signal Events: https://nodejs.org/api/process.html#signal-events - process.on('SIGINT', signalEventHandler); - process.on('SIGTERM', signalEventHandler); + ownedByOthers.clear(); + for (const [signal, handler] of processSignalHandlers) { + if (process.listeners(signal).some((listener) => !spinnerSignalListeners.has(listener))) { + ownedByOthers.add(signal); + } + spinnerSignalListeners.add(handler); + process.on(signal, handler); + } process.on('exit', handleExit); + process.on('newListener', trackListener); if (signal) { signal.addEventListener('abort', signalEventHandler); @@ -180,8 +215,11 @@ export const spinner = ({ const clearHooks = () => { process.removeListener('uncaughtExceptionMonitor', errorEventHandler); process.removeListener('unhandledRejection', errorEventHandler); - process.removeListener('SIGINT', signalEventHandler); - process.removeListener('SIGTERM', signalEventHandler); + process.removeListener('newListener', trackListener); + for (const [signal, handler] of processSignalHandlers) { + process.removeListener(signal, handler); + spinnerSignalListeners.delete(handler); + } process.removeListener('exit', handleExit); if (signal) { From 45fa6140def136b1a699825c6c11c70891330b47 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Wed, 11 Nov 2026 16:30:47 +0900 Subject: [PATCH 03/14] test(prompts): pin the platform in the spinner key tests The Ctrl+C and Escape tests expected process.kill, which only the POSIX branch calls. On Windows the spinner exits with 130 instead, so the tests failed there. Pin a POSIX platform unless a test asks for Windows. --- packages/prompts/src/__tests__/output.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/prompts/src/__tests__/output.spec.ts b/packages/prompts/src/__tests__/output.spec.ts index 2681b46e99..7808c3845d 100644 --- a/packages/prompts/src/__tests__/output.spec.ts +++ b/packages/prompts/src/__tests__/output.spec.ts @@ -34,8 +34,12 @@ function press(name: string, sequence: string, ctrl = false) { process.stdin.emit('keypress', sequence, { name, sequence, ctrl }); } -/** Starts a spinner that blocks keys, with process termination stubbed out. */ -function spinInTerminal() { +/** + * Starts a spinner that blocks keys, with process termination stubbed out. The + * platform is pinned, because it selects how the spinner ends the process. + */ +function spinInTerminal(platform: NodeJS.Platform = 'linux') { + Object.defineProperty(process, 'platform', { value: platform }); vi.stubEnv('CI', ''); const kill = vi.spyOn(process, 'kill').mockReturnValue(true); const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never); @@ -230,8 +234,7 @@ describe('spinner', () => { }); it('exits with 130 on Ctrl+C on Windows, which has no signals', () => { - Object.defineProperty(process, 'platform', { value: 'win32' }); - const { c, kill, exit } = spinInTerminal(); + const { c, kill, exit } = spinInTerminal('win32'); press('c', '\x03', true); expect(c.text()).toContain('■ Canceled'); expect(exit).toHaveBeenCalledWith(130); From c18ada455ad16e87d511103e1fcaac11d1b1de86 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Wed, 11 Nov 2026 16:31:18 +0900 Subject: [PATCH 04/14] fix(prompts): forward one Ctrl+C once when several spinners are active Each active spinner blocked input, so two spinners both received a single Ctrl+C and each sent SIGINT to the process group. A shutdown handler then saw two interrupts for one key press. Let only one spinner block input at a time, as the Rust progress row does. The others receive the forwarded signal through their signal listeners. --- .../src/__tests__/spinner-signals.spec.ts | 21 +++++++++++++++++++ packages/prompts/src/spinner.ts | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index ccafa0109e..22505520d4 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -106,6 +106,27 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { } }); + it('forwards one Ctrl+C once while two spinners are active', async () => { + const result = await run( + ` + import { setTimeout as delay } from 'node:timers/promises'; + let calls = 0; + process.on('SIGINT', async () => { + if (++calls > 1) process.exit(42); + await delay(50); + writeSync(1, 'cleanup complete\\n'); + process.exit(0); + }); + spinner({ output: output(true) }).start('First'); + spinner({ output: output(true) }).start('Second'); + process.send('ready'); + `, + (child) => child.stdin!.write('\x03'), + ); + expect(result).toMatchObject({ code: 0, signal: null, stderr: '' }); + expect(result.stdout).toContain('cleanup complete'); + }); + describe.each(['SIGINT', 'SIGTERM'] as const)('%s', (signal) => { it.each([1, 2])('ends the process by the signal with %i active spinners', async (count) => { const result = await run( diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 129bdfeb3f..1d4242b0c8 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -69,10 +69,18 @@ const endBySignal = (signal: ProcessSignal, pid: number): void => { } }; +// Only one spinner blocks input at a time, as the Rust progress row does, so +// that one Ctrl+C reaches the process group once. +let inputBlocked = false; + // clack's block() calls process.exit(0) on Ctrl+C and Escape, which reports an // interrupted command as successful. This copy reports Ctrl+C to the caller and // ignores every other key, as the Rust progress row does. const blockInput = (output: Writable, onInterrupt: () => void): (() => void) => { + if (inputBlocked) { + return () => {}; + } + inputBlocked = true; const input = process.stdin; const rl = readline.createInterface({ input, output, prompt: '', tabSize: 1 }); readline.emitKeypressEvents(input, rl); @@ -100,6 +108,7 @@ const blockInput = (output: Writable, onInterrupt: () => void): (() => void) => input.once('keypress', onKeypress); return () => { released = true; + inputBlocked = false; input.off('keypress', onKeypress); output.write(cursor.show); // Windows keeps raw mode: https://github.com/bombshell-dev/clack/issues/176 From 0be6ecfefe6ba2617f67af1133e488086707cc9f Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Wed, 11 Nov 2026 16:31:42 +0900 Subject: [PATCH 05/14] test(prompts): treat a zombie worker as terminated The Ctrl+C test waited for the worker's PID to disappear. Where nothing reaps orphans, as in some Linux containers, the worker stays a zombie after the group is interrupted, so the test timed out although the worker had ended. Read the process state on Linux and accept a zombie. --- .../src/__tests__/spinner-signals.spec.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index 22505520d4..781b008d17 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { once } from 'node:events'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -8,13 +8,24 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { build } from 'tsdown'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -function isAlive(pid: number) { +/** + * Whether `pid` is still executing. An orphan that has ended stays in the + * process table as a zombie where nothing reaps it, as in some containers. + */ +function isRunning(pid: number) { try { process.kill(pid, 0); - return true; } catch { return false; } + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); + // The state follows the command name, which can contain spaces and parentheses. + return stat[stat.lastIndexOf(')') + 2] !== 'Z'; + } catch { + // No procfs, as on macOS, where launchd reaps orphans. + return true; + } } // Windows has no signals or process groups; output.spec.ts covers its exit code. @@ -98,9 +109,9 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { expect(result).toMatchObject({ code: null, signal: 'SIGINT', stderr: '' }); expect(result.stdout).toContain('Canceled'); // A running package manager must not outlive the interrupted command. - await expect.poll(() => isAlive(worker), { timeout: 2000 }).toBe(false); + await expect.poll(() => isRunning(worker), { timeout: 2000 }).toBe(false); } finally { - if (worker && isAlive(worker)) { + if (worker && isRunning(worker)) { process.kill(worker, 'SIGKILL'); } } From e506d16db51d47fd2f668699efa0d0781bc9a926 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Wed, 11 Nov 2026 16:52:21 +0900 Subject: [PATCH 06/14] fix(prompts): decide signal ownership when the signal arrives A spinner remembered that another listener had been registered for a signal and never forgot it. After that listener was removed, a signal only stopped the spinner and the command kept running. Check the registered listeners when the signal arrives instead. Node removes a one-shot listener just before calling it, so a removal during the current delivery still counts as another owner, and its cleanup is not cut short. --- .../src/__tests__/spinner-signals.spec.ts | 15 +++++++++++ packages/prompts/src/spinner.ts | 26 ++++++++++--------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index 781b008d17..03eac131f2 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -153,6 +153,21 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { expect(result.stdout).toContain('Canceled'); }); + it('ends the process by the signal after another listener came and went', async () => { + const result = await run( + ` + const signal = ${JSON.stringify(signal)}; + const other = () => {}; + spinner({ output: output(false) }).start('Working'); + process.on(signal, other); + process.off(signal, other); + process.send('ready'); + `, + (child) => child.kill(signal), + ); + expect(result).toMatchObject({ code: null, signal, stderr: '' }); + }); + // The shutdown exits with 42 when the signal reaches it twice, and a signal // raised again during its cleanup ends the process before it reports. it.each([ diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 1d4242b0c8..3a3d3763ff 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -170,21 +170,27 @@ export const spinner = ({ const errorEventHandler = () => handleExit(2); const signalEventHandler = () => handleExit(1); - // Events another listener handles: one registered before this spinner started - // or added while it spins. For a signal, that owner decides how the process ends. - const ownedByOthers = new Set(); - const trackListener = (event: string | symbol, listener: unknown) => { + // Node removes a one-shot listener just before it calls it, so a listener that + // is handling the current signal can already be gone when this spinner's own + // listener runs. Remember a removal until the current callback returns. + const removedInDelivery = new Set(); + const trackRemoval = (event: string | symbol, listener: unknown) => { if (!spinnerSignalListeners.has(listener)) { - ownedByOthers.add(event); + removedInDelivery.add(event); + process.nextTick(() => removedInDelivery.delete(event)); } }; + // Another listener that handles the signal decides how the process ends. + const ownedByAnother = (signal: ProcessSignal) => + removedInDelivery.has(signal) || + process.listeners(signal).some((listener) => !spinnerSignalListeners.has(listener)); // A listener replaces the default action, so a spinner that is the only owner // raises the signal again once its hooks are cleared. The process then ends // with the signal status, as the Rust progress row does. An emit without the // signal name did not come from the system and only stops the spinner. const processSignalHandlers = PROCESS_SIGNALS.map((signal) => { const handler = (received?: NodeJS.Signals) => { - const raise = received === signal && !ownedByOthers.has(signal); + const raise = received === signal && !ownedByAnother(signal); handleExit(1); if (raise) { endBySignal(signal, process.pid); @@ -205,16 +211,12 @@ export const spinner = ({ // Reference: https://nodejs.org/api/process.html#event-unhandledrejection process.on('unhandledRejection', errorEventHandler); // Reference Signal Events: https://nodejs.org/api/process.html#signal-events - ownedByOthers.clear(); for (const [signal, handler] of processSignalHandlers) { - if (process.listeners(signal).some((listener) => !spinnerSignalListeners.has(listener))) { - ownedByOthers.add(signal); - } spinnerSignalListeners.add(handler); process.on(signal, handler); } process.on('exit', handleExit); - process.on('newListener', trackListener); + process.on('removeListener', trackRemoval); if (signal) { signal.addEventListener('abort', signalEventHandler); @@ -224,7 +226,7 @@ export const spinner = ({ const clearHooks = () => { process.removeListener('uncaughtExceptionMonitor', errorEventHandler); process.removeListener('unhandledRejection', errorEventHandler); - process.removeListener('newListener', trackListener); + process.removeListener('removeListener', trackRemoval); for (const [signal, handler] of processSignalHandlers) { process.removeListener(signal, handler); spinnerSignalListeners.delete(handler); From 4d52b1698f38ef0c4afd95d7730f7e9523b14f3e Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 10:10:59 +0900 Subject: [PATCH 07/14] chore(prompts): shorten the spinner comments --- packages/prompts/src/spinner.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 3a3d3763ff..e779235abe 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -57,10 +57,10 @@ const formatTimer = (durationMs: number): string => { const PROCESS_SIGNALS = ['SIGINT', 'SIGTERM'] as const; type ProcessSignal = (typeof PROCESS_SIGNALS)[number]; -// Signal listeners of active spinners, which are not another owner of a signal. +// Signal listeners of active spinners. They do not count as another owner. const spinnerSignalListeners = new Set(); -// Windows has no signals, so exit there with the status a shell reports for one. +// Windows has no signals, so exit with the matching status there. const endBySignal = (signal: ProcessSignal, pid: number): void => { if (process.platform === 'win32') { process.exit(signal === 'SIGINT' ? 130 : 143); @@ -69,13 +69,10 @@ const endBySignal = (signal: ProcessSignal, pid: number): void => { } }; -// Only one spinner blocks input at a time, as the Rust progress row does, so -// that one Ctrl+C reaches the process group once. +// One spinner blocks input at a time, so one Ctrl+C is forwarded once. let inputBlocked = false; -// clack's block() calls process.exit(0) on Ctrl+C and Escape, which reports an -// interrupted command as successful. This copy reports Ctrl+C to the caller and -// ignores every other key, as the Rust progress row does. +// clack's block() exits with 0 on Ctrl+C. This copy reports Ctrl+C and ignores other keys. const blockInput = (output: Writable, onInterrupt: () => void): (() => void) => { if (inputBlocked) { return () => {}; @@ -170,9 +167,7 @@ export const spinner = ({ const errorEventHandler = () => handleExit(2); const signalEventHandler = () => handleExit(1); - // Node removes a one-shot listener just before it calls it, so a listener that - // is handling the current signal can already be gone when this spinner's own - // listener runs. Remember a removal until the current callback returns. + // A one-shot listener is removed before it runs, so keep a removal until this callback returns. const removedInDelivery = new Set(); const trackRemoval = (event: string | symbol, listener: unknown) => { if (!spinnerSignalListeners.has(listener)) { @@ -180,14 +175,12 @@ export const spinner = ({ process.nextTick(() => removedInDelivery.delete(event)); } }; - // Another listener that handles the signal decides how the process ends. + // Another listener of the signal decides how the process ends. const ownedByAnother = (signal: ProcessSignal) => removedInDelivery.has(signal) || process.listeners(signal).some((listener) => !spinnerSignalListeners.has(listener)); - // A listener replaces the default action, so a spinner that is the only owner - // raises the signal again once its hooks are cleared. The process then ends - // with the signal status, as the Rust progress row does. An emit without the - // signal name did not come from the system and only stops the spinner. + // A listener replaces the default action, so the only owner raises the signal again. + // An emit without the signal name only stops the spinner. const processSignalHandlers = PROCESS_SIGNALS.map((signal) => { const handler = (received?: NodeJS.Signals) => { const raise = received === signal && !ownedByAnother(signal); @@ -198,8 +191,7 @@ export const spinner = ({ }; return [signal, handler] as const; }); - // Raw mode turns Ctrl+C into a key press. Send SIGINT to the foreground process - // group, as the terminal does outside raw mode, so that child processes stop too. + // Raw mode turns Ctrl+C into a key, so send SIGINT to the process group as the terminal would. const interrupt = () => { handleExit(1); endBySignal('SIGINT', 0); From cb27a4cef49394d09e0053f3a0ec4ad2e1a7ba10 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 13:53:31 +0900 Subject: [PATCH 08/14] fix(prompts): keep the spinner key listener between keys of one chunk The key listener was registered for one key and again only after the echoed key had been erased. A Ctrl+C that followed another key in the same chunk, as happens after typing while the event loop is blocked, found no listener and was dropped. clack's block() has the same pattern. Keep the listener registered while the spinner blocks input. Every key of a chunk is now erased as well. --- packages/prompts/src/__tests__/output.spec.ts | 5 ++--- packages/prompts/src/spinner.ts | 17 +++++------------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/prompts/src/__tests__/output.spec.ts b/packages/prompts/src/__tests__/output.spec.ts index 7808c3845d..78bc8d0707 100644 --- a/packages/prompts/src/__tests__/output.spec.ts +++ b/packages/prompts/src/__tests__/output.spec.ts @@ -241,15 +241,14 @@ describe('spinner', () => { expect(kill).not.toHaveBeenCalled(); }); - it('ignores Escape and keeps waiting for Ctrl+C', async () => { + it('ignores Escape and handles a Ctrl+C that follows in the same chunk', () => { const { c, s, kill, exit } = spinInTerminal(); press('escape', '\x1b'); expect(s.isCancelled).toBe(false); expect(c.text()).not.toContain('Canceled'); expect(kill).not.toHaveBeenCalled(); expect(exit).not.toHaveBeenCalled(); - // The next key is awaited once the echoed one is erased. - await new Promise((resolve) => setImmediate(resolve)); + // Keys of one chunk arrive without a tick in between. press('c', '\x03', true); expect(kill).toHaveBeenCalledWith(0, 'SIGINT'); }); diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index e779235abe..8809d2df42 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -84,27 +84,20 @@ const blockInput = (output: Writable, onInterrupt: () => void): (() => void) => if (input.isTTY) { input.setRawMode(true); } - let released = false; + // One chunk can carry several keys, so the listener stays registered between them. const onKeypress = (_char: string | undefined, { name, sequence }: readline.Key): void => { if (sequence === '\x03') { onInterrupt(); return; } - // Erase the echoed key, then wait for the next one. + // Erase the echoed key. const [dx, dy] = name === 'return' ? [0, -1] : [-1, 0]; - readline.moveCursor(output, dx, dy, () => { - readline.clearLine(output, 1, () => { - // The spinner can stop before these writes finish. - if (!released) { - input.once('keypress', onKeypress); - } - }); - }); + readline.moveCursor(output, dx, dy); + readline.clearLine(output, 1); }; output.write(cursor.hide); - input.once('keypress', onKeypress); + input.on('keypress', onKeypress); return () => { - released = true; inputBlocked = false; input.off('keypress', onKeypress); output.write(cursor.show); From c68364d07eab50ec7dc26b3156d8166d36c10be2 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 14:36:42 +0900 Subject: [PATCH 09/14] test(prompts): drop a restated tsdown option and a leftover wait The tsdown extension option repeated the default. The wait in the key test covered the deferred listener registration that no longer exists. --- packages/prompts/src/__tests__/output.spec.ts | 3 +-- packages/prompts/src/__tests__/spinner-signals.spec.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/prompts/src/__tests__/output.spec.ts b/packages/prompts/src/__tests__/output.spec.ts index 78bc8d0707..02ae40847b 100644 --- a/packages/prompts/src/__tests__/output.spec.ts +++ b/packages/prompts/src/__tests__/output.spec.ts @@ -253,11 +253,10 @@ describe('spinner', () => { expect(kill).toHaveBeenCalledWith(0, 'SIGINT'); }); - it('stops listening for keys once the spinner stops', async () => { + it('stops listening for keys once the spinner stops', () => { const { s, kill } = spinInTerminal(); press('a', 'a'); s.stop('Built'); - await new Promise((resolve) => setImmediate(resolve)); press('c', '\x03', true); expect(kill).not.toHaveBeenCalled(); }); diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index 03eac131f2..86abde39b9 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -40,7 +40,6 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { config: false, entry: [fileURLToPath(new URL('../spinner.ts', import.meta.url))], outDir: directory, - outExtensions: () => ({ js: '.mjs' }), deps: { alwaysBundle: [/.*/], onlyBundle: false }, dts: false, logLevel: 'silent', From f33444bbdac1a7bf1e417f21e1b204e906f9aa01 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 14:39:25 +0900 Subject: [PATCH 10/14] chore(prompts): remove spinner comments that restate the code --- packages/prompts/src/__tests__/output.spec.ts | 5 ----- packages/prompts/src/__tests__/spinner-signals.spec.ts | 1 - packages/prompts/src/spinner.ts | 2 -- 3 files changed, 8 deletions(-) diff --git a/packages/prompts/src/__tests__/output.spec.ts b/packages/prompts/src/__tests__/output.spec.ts index 02ae40847b..5e1326b77e 100644 --- a/packages/prompts/src/__tests__/output.spec.ts +++ b/packages/prompts/src/__tests__/output.spec.ts @@ -34,10 +34,6 @@ function press(name: string, sequence: string, ctrl = false) { process.stdin.emit('keypress', sequence, { name, sequence, ctrl }); } -/** - * Starts a spinner that blocks keys, with process termination stubbed out. The - * platform is pinned, because it selects how the spinner ends the process. - */ function spinInTerminal(platform: NodeJS.Platform = 'linux') { Object.defineProperty(process, 'platform', { value: platform }); vi.stubEnv('CI', ''); @@ -248,7 +244,6 @@ describe('spinner', () => { expect(c.text()).not.toContain('Canceled'); expect(kill).not.toHaveBeenCalled(); expect(exit).not.toHaveBeenCalled(); - // Keys of one chunk arrive without a tick in between. press('c', '\x03', true); expect(kill).toHaveBeenCalledWith(0, 'SIGINT'); }); diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index 86abde39b9..cb1d299444 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -107,7 +107,6 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { ); expect(result).toMatchObject({ code: null, signal: 'SIGINT', stderr: '' }); expect(result.stdout).toContain('Canceled'); - // A running package manager must not outlive the interrupted command. await expect.poll(() => isRunning(worker), { timeout: 2000 }).toBe(false); } finally { if (worker && isRunning(worker)) { diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 8809d2df42..d3e7f69795 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -84,7 +84,6 @@ const blockInput = (output: Writable, onInterrupt: () => void): (() => void) => if (input.isTTY) { input.setRawMode(true); } - // One chunk can carry several keys, so the listener stays registered between them. const onKeypress = (_char: string | undefined, { name, sequence }: readline.Key): void => { if (sequence === '\x03') { onInterrupt(); @@ -168,7 +167,6 @@ export const spinner = ({ process.nextTick(() => removedInDelivery.delete(event)); } }; - // Another listener of the signal decides how the process ends. const ownedByAnother = (signal: ProcessSignal) => removedInDelivery.has(signal) || process.listeners(signal).some((listener) => !spinnerSignalListeners.has(listener)); From 048f80fc4a518fd10d9846a46ef92481acf4a75b Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 14:41:37 +0900 Subject: [PATCH 11/14] test(prompts): pin the platform with a spy in the spinner key tests The helper replaced process.platform by hand and a nested hook put it back, so a call from another block would have leaked the value. A spy is restored by the existing vi.restoreAllMocks(). --- packages/prompts/src/__tests__/output.spec.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/prompts/src/__tests__/output.spec.ts b/packages/prompts/src/__tests__/output.spec.ts index 5e1326b77e..b814cdbc7f 100644 --- a/packages/prompts/src/__tests__/output.spec.ts +++ b/packages/prompts/src/__tests__/output.spec.ts @@ -35,7 +35,7 @@ function press(name: string, sequence: string, ctrl = false) { } function spinInTerminal(platform: NodeJS.Platform = 'linux') { - Object.defineProperty(process, 'platform', { value: platform }); + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform); vi.stubEnv('CI', ''); const kill = vi.spyOn(process, 'kill').mockReturnValue(true); const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never); @@ -214,12 +214,6 @@ describe('spinner', () => { }); describe('keys while spinning in a terminal', () => { - const platform = process.platform; - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: platform }); - }); - it('interrupts the foreground process group on Ctrl+C instead of exiting with 0', () => { const { c, s, kill, exit } = spinInTerminal(); press('c', '\x03', true); From 0140e7ea8943bbfd8ffab5d3ce3083cedb83ea7c Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 14:42:10 +0900 Subject: [PATCH 12/14] test(prompts): resolve the spinner source with import.meta.dirname --- packages/prompts/src/__tests__/spinner-signals.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index cb1d299444..ff1879fd5f 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -3,7 +3,7 @@ import { once } from 'node:events'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { build } from 'tsdown'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; @@ -38,7 +38,7 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { // Build current source rather than relying on potentially stale package output. await build({ config: false, - entry: [fileURLToPath(new URL('../spinner.ts', import.meta.url))], + entry: [join(import.meta.dirname, '../spinner.ts')], outDir: directory, deps: { alwaysBundle: [/.*/], onlyBundle: false }, dts: false, From 7726207059ebe1523563c285873c89d52df21837 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 16:03:34 +0900 Subject: [PATCH 13/14] fix(prompts): run the spinner signal listener before cleanup observers signal-exit ends the process only when its listener is the last one left, and a spinner leaves the shutdown to any other listener. With signal-exit registered first, each waited for the other, so the command kept running after Ctrl+C with two spinners or after a signal. Register the spinner listener first. It has stopped and removed itself by the time other listeners run, and a real shutdown handler still receives the signal once. signal-exit joins the dev dependencies for the regression tests. --- packages/prompts/package.json | 1 + .../src/__tests__/spinner-signals.spec.ts | 31 +++++++++++++++++++ packages/prompts/src/spinner.ts | 3 +- pnpm-lock.yaml | 3 ++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/prompts/package.json b/packages/prompts/package.json index a771ec4729..f1fc430a82 100644 --- a/packages/prompts/package.json +++ b/packages/prompts/package.json @@ -35,6 +35,7 @@ "fast-string-width": "^3.0.0", "fast-wrap-ansi": "^0.2.0", "is-unicode-supported": "^2.0.0", + "signal-exit": "catalog:", "tsdown": "catalog:" } } diff --git a/packages/prompts/src/__tests__/spinner-signals.spec.ts b/packages/prompts/src/__tests__/spinner-signals.spec.ts index ff1879fd5f..c3e1c6ac49 100644 --- a/packages/prompts/src/__tests__/spinner-signals.spec.ts +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -69,6 +69,8 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { */ async function run(script: string, act: (child: ChildProcess, message: unknown) => void) { const child = spawn(process.execPath, ['--input-type=module', '--eval', prelude + script], { + // The scripts resolve signal-exit from this package. + cwd: join(import.meta.dirname, '../..'), detached: true, env: { ...process.env, CI: '' }, stdio: ['pipe', 'pipe', 'pipe', 'ipc'], @@ -136,7 +138,36 @@ describe.skipIf(process.platform === 'win32')('spinner interrupts', () => { expect(result.stdout).toContain('cleanup complete'); }); + it('ends by SIGINT on Ctrl+C when signal-exit was registered before two spinners', async () => { + const result = await run( + ` + import { onExit } from 'signal-exit'; + onExit(() => writeSync(1, 'cleanup hook ran\\n')); + spinner({ output: output(true) }).start('First'); + spinner({ output: output(true) }).start('Second'); + process.send('ready'); + `, + (child) => child.stdin!.write('\x03'), + ); + expect(result).toMatchObject({ code: null, signal: 'SIGINT', stderr: '' }); + expect(result.stdout).toContain('cleanup hook ran'); + }); + describe.each(['SIGINT', 'SIGTERM'] as const)('%s', (signal) => { + it('ends the process by the signal when signal-exit was registered first', async () => { + const result = await run( + ` + import { onExit } from 'signal-exit'; + onExit(() => writeSync(1, 'cleanup hook ran\\n')); + spinner({ output: output(false) }).start('Working'); + process.send('ready'); + `, + (child) => child.kill(signal), + ); + expect(result).toMatchObject({ code: null, signal, stderr: '' }); + expect(result.stdout).toContain('cleanup hook ran'); + }); + it.each([1, 2])('ends the process by the signal with %i active spinners', async (count) => { const result = await run( ` diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index d3e7f69795..e0310aec9f 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -196,7 +196,8 @@ export const spinner = ({ // Reference Signal Events: https://nodejs.org/api/process.html#signal-events for (const [signal, handler] of processSignalHandlers) { spinnerSignalListeners.add(handler); - process.on(signal, handler); + // Run first, so that an observer such as signal-exit finds no spinner listener left. + process.prependListener(signal, handler); } process.on('exit', handleExit); process.on('removeListener', trackRemoval); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 023b9c79c7..9b07df63ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -740,6 +740,9 @@ importers: is-unicode-supported: specifier: ^2.0.0 version: 2.1.0 + signal-exit: + specifier: 'catalog:' + version: 4.1.0 tsdown: specifier: 'catalog:' version: 0.23.4(@arethetypeswrong/core@0.18.2)(@tsdown/css@0.23.4)(@tsdown/exe@0.23.4)(@typescript/native-preview@7.0.0-dev.20260707.2)(@vitejs/devtools@0.8.2(crossws@0.4.17(srvx@1.0.6))(srvx@1.0.6)(vite@packages+core))(@volar/typescript@2.4.28)(@vue/language-core@3.3.12)(oxc-resolver@11.24.2)(publint@0.3.24)(tsx@4.23.15)(typescript@7.0.2)(unplugin-unused@0.5.6)(unrun@0.3.1)(vue-tsc@3.3.12(typescript@7.0.2)) From 6b549ba709ddad72d331da60ef2b7c77d02fba46 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Thu, 12 Nov 2026 16:21:39 +0900 Subject: [PATCH 14/14] refactor(prompts): name process signals apart from the abort signal The loops over the process signals reused the name of the spinner's AbortSignal option, which the next lines then read. Rename the process signal bindings, and drop two comments that restate the code. --- packages/prompts/src/spinner.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index e0310aec9f..384ba8de22 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -57,7 +57,6 @@ const formatTimer = (durationMs: number): string => { const PROCESS_SIGNALS = ['SIGINT', 'SIGTERM'] as const; type ProcessSignal = (typeof PROCESS_SIGNALS)[number]; -// Signal listeners of active spinners. They do not count as another owner. const spinnerSignalListeners = new Set(); // Windows has no signals, so exit with the matching status there. @@ -69,7 +68,6 @@ const endBySignal = (signal: ProcessSignal, pid: number): void => { } }; -// One spinner blocks input at a time, so one Ctrl+C is forwarded once. let inputBlocked = false; // clack's block() exits with 0 on Ctrl+C. This copy reports Ctrl+C and ignores other keys. @@ -167,20 +165,20 @@ export const spinner = ({ process.nextTick(() => removedInDelivery.delete(event)); } }; - const ownedByAnother = (signal: ProcessSignal) => - removedInDelivery.has(signal) || - process.listeners(signal).some((listener) => !spinnerSignalListeners.has(listener)); + const ownedByAnother = (processSignal: ProcessSignal) => + removedInDelivery.has(processSignal) || + process.listeners(processSignal).some((listener) => !spinnerSignalListeners.has(listener)); // A listener replaces the default action, so the only owner raises the signal again. // An emit without the signal name only stops the spinner. - const processSignalHandlers = PROCESS_SIGNALS.map((signal) => { + const processSignalHandlers = PROCESS_SIGNALS.map((processSignal) => { const handler = (received?: NodeJS.Signals) => { - const raise = received === signal && !ownedByAnother(signal); + const raise = received === processSignal && !ownedByAnother(processSignal); handleExit(1); if (raise) { - endBySignal(signal, process.pid); + endBySignal(processSignal, process.pid); } }; - return [signal, handler] as const; + return [processSignal, handler] as const; }); // Raw mode turns Ctrl+C into a key, so send SIGINT to the process group as the terminal would. const interrupt = () => { @@ -194,10 +192,10 @@ export const spinner = ({ // Reference: https://nodejs.org/api/process.html#event-unhandledrejection process.on('unhandledRejection', errorEventHandler); // Reference Signal Events: https://nodejs.org/api/process.html#signal-events - for (const [signal, handler] of processSignalHandlers) { + for (const [processSignal, handler] of processSignalHandlers) { spinnerSignalListeners.add(handler); // Run first, so that an observer such as signal-exit finds no spinner listener left. - process.prependListener(signal, handler); + process.prependListener(processSignal, handler); } process.on('exit', handleExit); process.on('removeListener', trackRemoval); @@ -211,8 +209,8 @@ export const spinner = ({ process.removeListener('uncaughtExceptionMonitor', errorEventHandler); process.removeListener('unhandledRejection', errorEventHandler); process.removeListener('removeListener', trackRemoval); - for (const [signal, handler] of processSignalHandlers) { - process.removeListener(signal, handler); + for (const [processSignal, handler] of processSignalHandlers) { + process.removeListener(processSignal, handler); spinnerSignalListeners.delete(handler); } process.removeListener('exit', handleExit);