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__/output.spec.ts b/packages/prompts/src/__tests__/output.spec.ts index ebba20dbe5..b814cdbc7f 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 }); +} + +function spinInTerminal(platform: NodeJS.Platform = 'linux') { + 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); + 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,42 @@ describe('spinner', () => { expect(c.text()).toContain('■ Canceled'); expect(process.listenerCount('SIGINT')).toBe(before); }); + + describe('keys while spinning in a terminal', () => { + 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', () => { + const { c, kill, exit } = spinInTerminal('win32'); + press('c', '\x03', true); + expect(c.text()).toContain('■ Canceled'); + expect(exit).toHaveBeenCalledWith(130); + expect(kill).not.toHaveBeenCalled(); + }); + + 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(); + press('c', '\x03', true); + expect(kill).toHaveBeenCalledWith(0, 'SIGINT'); + }); + + it('stops listening for keys once the spinner stops', () => { + const { s, kill } = spinInTerminal(); + press('a', 'a'); + s.stop('Built'); + 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..c3e1c6ac49 --- /dev/null +++ b/packages/prompts/src/__tests__/spinner-signals.spec.ts @@ -0,0 +1,253 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { build } from 'tsdown'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +/** + * 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); + } 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. +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: [join(import.meta.dirname, '../spinner.ts')], + outDir: directory, + 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], { + // The scripts resolve signal-exit from this package. + cwd: join(import.meta.dirname, '../..'), + 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'); + await expect.poll(() => isRunning(worker), { timeout: 2000 }).toBe(false); + } finally { + if (worker && isRunning(worker)) { + process.kill(worker, 'SIGKILL'); + } + } + }); + + 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'); + }); + + 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( + ` + 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'); + }); + + 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([ + ['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 a9915488f2..384ba8de22 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,60 @@ 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]; + +const spinnerSignalListeners = new Set(); + +// 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); + } else { + process.kill(pid, signal); + } +}; + +let inputBlocked = false; + +// 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 () => {}; + } + inputBlocked = true; + const input = process.stdin; + const rl = readline.createInterface({ input, output, prompt: '', tabSize: 1 }); + readline.emitKeypressEvents(input, rl); + if (input.isTTY) { + input.setRawMode(true); + } + const onKeypress = (_char: string | undefined, { name, sequence }: readline.Key): void => { + if (sequence === '\x03') { + onInterrupt(); + return; + } + // Erase the echoed key. + const [dx, dy] = name === 'return' ? [0, -1] : [-1, 0]; + readline.moveCursor(output, dx, dy); + readline.clearLine(output, 1); + }; + output.write(cursor.hide); + input.on('keypress', onKeypress); + return () => { + inputBlocked = false; + 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 +157,34 @@ export const spinner = ({ const errorEventHandler = () => handleExit(2); const signalEventHandler = () => handleExit(1); + // 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)) { + removedInDelivery.add(event); + process.nextTick(() => removedInDelivery.delete(event)); + } + }; + 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((processSignal) => { + const handler = (received?: NodeJS.Signals) => { + const raise = received === processSignal && !ownedByAnother(processSignal); + handleExit(1); + if (raise) { + endBySignal(processSignal, process.pid); + } + }; + 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 = () => { + handleExit(1); + endBySignal('SIGINT', 0); + }; const registerHooks = () => { // Reference: https://nodejs.org/api/process.html#event-uncaughtexception @@ -107,9 +192,13 @@ 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); + 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(processSignal, handler); + } process.on('exit', handleExit); + process.on('removeListener', trackRemoval); if (signal) { signal.addEventListener('abort', signalEventHandler); @@ -119,8 +208,11 @@ export const spinner = ({ const clearHooks = () => { process.removeListener('uncaughtExceptionMonitor', errorEventHandler); process.removeListener('unhandledRejection', errorEventHandler); - process.removeListener('SIGINT', signalEventHandler); - process.removeListener('SIGTERM', signalEventHandler); + process.removeListener('removeListener', trackRemoval); + for (const [processSignal, handler] of processSignalHandlers) { + process.removeListener(processSignal, handler); + spinnerSignalListeners.delete(handler); + } process.removeListener('exit', handleExit); if (signal) { @@ -151,7 +243,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) { 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))