Skip to content
Merged
Show file tree
Hide file tree
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 Nov 11, 2026
19eee2b
fix(prompts): end by the received signal when only a spinner owns it
jong-kyung Nov 11, 2026
45fa614
test(prompts): pin the platform in the spinner key tests
jong-kyung Nov 11, 2026
c18ada4
fix(prompts): forward one Ctrl+C once when several spinners are active
jong-kyung Nov 11, 2026
0be6ecf
test(prompts): treat a zombie worker as terminated
jong-kyung Nov 11, 2026
e506d16
fix(prompts): decide signal ownership when the signal arrives
jong-kyung Nov 11, 2026
4d52b16
chore(prompts): shorten the spinner comments
jong-kyung Nov 12, 2026
8ec4528
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung Nov 12, 2026
3332f0f
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung Nov 12, 2026
0da1100
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung Nov 12, 2026
cb27a4c
fix(prompts): keep the spinner key listener between keys of one chunk
jong-kyung Nov 12, 2026
e9f404b
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung Nov 12, 2026
c68364d
test(prompts): drop a restated tsdown option and a leftover wait
jong-kyung Nov 12, 2026
f33444b
chore(prompts): remove spinner comments that restate the code
jong-kyung Nov 12, 2026
048f80f
test(prompts): pin the platform with a spy in the spinner key tests
jong-kyung Nov 12, 2026
0140e7e
test(prompts): resolve the spinner source with import.meta.dirname
jong-kyung Nov 12, 2026
7726207
fix(prompts): run the spinner signal listener before cleanup observers
jong-kyung Nov 12, 2026
6b549ba
refactor(prompts): name process signals apart from the abort signal
jong-kyung Nov 12, 2026
098d02e
Merge branch 'main' into fix/prompts-spinner-signal-exit
jong-kyung Nov 12, 2026
f5cf9b1
Merge branch 'main' into fix/prompts-spinner-signal-exit
fengmk2 Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/prompts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
}
}
54 changes: 54 additions & 0 deletions packages/prompts/src/__tests__/output.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +57,7 @@ afterEach(() => {
updateSettings({ withGuide: false });
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

describe('output identity', () => {
Expand Down Expand Up @@ -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();
Comment thread
jong-kyung marked this conversation as resolved.
Resolved
});

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();
});
});
});
253 changes: 253 additions & 0 deletions packages/prompts/src/__tests__/spinner-signals.spec.ts
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');
});
});
Loading
Loading