export type CliInterruptReason = 'sigint' | 'sigterm' | 'sighup' | 'watchdog'; type CliInterruptSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP'; export interface CliInterruptSource { on(event: CliInterruptSignal, listener: () => void): unknown; removeListener(event: CliInterruptSignal, listener: () => void): unknown; } export interface CliInterruptInputSource { readonly isTTY?: boolean; readonly isRaw?: boolean; readonly readableFlowing?: boolean | null; on(event: 'data', listener: (chunk: unknown) => void): unknown; removeListener(event: 'data', listener: (chunk: unknown) => void): unknown; setRawMode?(enabled: boolean): unknown; pause?(): unknown; } export interface CliInterruptWaiter { /** Resolves on the first stop signal or watchdog timeout. */ readonly result: Promise; /** Release signal handlers after asynchronous finalization has completed. */ dispose(): void; } export interface CliInterruptWaiterOptions { source?: CliInterruptSource; input?: CliInterruptInputSource; /** Called after restoring the terminal when Ctrl+C is pressed again. */ forceExit?: (exitCode: number) => void; } const activeInterruptWaiters = new WeakMap(); const noop = () => {}; const sigintExitCode = 130; function guardTerminalCtrlC( input: CliInterruptInputSource | undefined, onInterrupt: () => void, ): () => void { if (!input?.isTTY || !input.setRawMode) return noop; const wasRaw = input.isRaw === true; const wasFlowing = input.readableFlowing === true; const onData = (chunk: unknown) => { const includesCtrlC = (typeof chunk === 'string' && chunk.includes('\u0003')) || (chunk instanceof Uint8Array && chunk.includes(3)); if (includesCtrlC) onInterrupt(); }; const dispose = () => { input.removeListener('data', onData); try { if (!wasFlowing) input.pause?.(); } finally { if (!wasRaw) input.setRawMode?.(false); } }; input.setRawMode(true); try { input.on('data', onData); } catch (error) { dispose(); throw error; } return dispose; } function registerCliInterruptWaiter(source: CliInterruptSource): () => void { const key = source as object; activeInterruptWaiters.set(key, (activeInterruptWaiters.get(key) ?? 0) + 1); let active = true; return () => { if (!active) return; active = false; const remaining = (activeInterruptWaiters.get(key) ?? 1) - 1; if (remaining > 0) { activeInterruptWaiters.set(key, remaining); } else { activeInterruptWaiters.delete(key); } }; } /** Whether a foreground CLI command is currently waiting for this source. */ export function hasActiveCliInterruptWaiter( source: CliInterruptSource = process, ): boolean { return (activeInterruptWaiters.get(source as object) ?? 0) > 0; } /** * Keep graceful-stop handlers installed until the caller has finished saving. * * Package runners such as pnpm can deliver SIGINT to the foreground child, * immediately follow it with SIGTERM, then cause SIGHUP when the runner exits * and its pseudo-terminal closes. Resolving on the first signal is not enough: * removing any handler at that point lets a subsequent signal kill the child * during asynchronous artifact finalization. * * On a TTY, Ctrl+C is captured as raw input so the package runner itself stays * alive until the child has saved and restored the terminal. Signal handlers * remain as the graceful-stop path for externally delivered termination. */ export function createCliInterruptWaiter( watchdogMs: number, options: CliInterruptWaiterOptions = {}, ): CliInterruptWaiter { const source = options.source ?? process; const input = options.input ?? (source === process ? process.stdin : undefined); const forceExit = options.forceExit ?? (source === process ? (exitCode: number) => { process.exit(exitCode); } : undefined); const unregisterWaiter = registerCliInterruptWaiter(source); let timer: ReturnType | undefined; let finished = false; let disposed = false; let disposeInput = noop; let resolveResult!: (reason: CliInterruptReason) => void; let rejectResult!: (error: unknown) => void; const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; }); const finish = (reason: CliInterruptReason) => { if (finished) return; finished = true; if (timer) { clearTimeout(timer); timer = undefined; } resolveResult(reason); }; const onSigint = () => finish('sigint'); const onSigterm = () => finish('sigterm'); const onSighup = () => finish('sighup'); function dispose() { if (disposed) return; disposed = true; source.removeListener('SIGINT', onSigint); source.removeListener('SIGTERM', onSigterm); source.removeListener('SIGHUP', onSighup); try { disposeInput(); } finally { if (timer) { clearTimeout(timer); timer = undefined; } unregisterWaiter(); } } const onTerminalCtrlC = () => { if (!finished) { finish('sigint'); return; } // The first Ctrl+C protects asynchronous artifact finalization. A second // explicit Ctrl+C is the user's escape hatch if device or file I/O hangs. dispose(); forceExit?.(sigintExitCode); }; try { source.on('SIGINT', onSigint); source.on('SIGTERM', onSigterm); source.on('SIGHUP', onSighup); disposeInput = guardTerminalCtrlC(input, onTerminalCtrlC); if (watchdogMs > 0) { timer = setTimeout(() => finish('watchdog'), watchdogMs); } } catch (error) { dispose(); rejectResult(error); } return { result, dispose }; } /** * Wait for one stop request and release the handlers immediately afterwards. * Long-running finalizers should use {@link createCliInterruptWaiter} instead. */ export function waitForCliInterrupt( watchdogMs: number, source: CliInterruptSource = process, input: CliInterruptInputSource | undefined = source === process ? process.stdin : undefined, ): Promise { const waiter = createCliInterruptWaiter(watchdogMs, { source, input }); return waiter.result.finally(waiter.dispose); }