Repository navigation
fix(prompts): end an interrupted spinner with the signal status #3432
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
fengmk2
merged 20 commits into
voidzero-dev:main
from
jong-kyung:fix/prompts-spinner-signal-exit
Nov 12, 2026
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
59a201b
fix(prompts): end by SIGINT when Ctrl+C interrupts a spinner
jong-kyung 19eee2b
fix(prompts): end by the received signal when only a spinner owns it
jong-kyung 45fa614
test(prompts): pin the platform in the spinner key tests
jong-kyung c18ada4
fix(prompts): forward one Ctrl+C once when several spinners are active
jong-kyung 0be6ecf
test(prompts): treat a zombie worker as terminated
jong-kyung e506d16
fix(prompts): decide signal ownership when the signal arrives
jong-kyung 4d52b16
chore(prompts): shorten the spinner comments
jong-kyung 8ec4528
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung 3332f0f
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung 0da1100
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung cb27a4c
fix(prompts): keep the spinner key listener between keys of one chunk
jong-kyung e9f404b
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung c68364d
test(prompts): drop a restated tsdown option and a leftover wait
jong-kyung f33444b
chore(prompts): remove spinner comments that restate the code
jong-kyung 048f80f
test(prompts): pin the platform with a spy in the spinner key tests
jong-kyung 0140e7e
test(prompts): resolve the spinner source with import.meta.dirname
jong-kyung 7726207
fix(prompts): run the spinner signal listener before cleanup observers
jong-kyung 6b549ba
refactor(prompts): name process signals apart from the abort signal
jong-kyung 098d02e
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung f5cf9b1
Merge branch 'main' into fix/prompts-spinner-signal-exit
fengmk2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.