diff --git a/src/utils/ShellCommand.test.ts b/src/utils/ShellCommand.test.ts index 03f5e87f87..306327bef0 100644 --- a/src/utils/ShellCommand.test.ts +++ b/src/utils/ShellCommand.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events' +import { spawn } from 'child_process' import { expect, test } from 'bun:test' import { wrapSpawn } from './ShellCommand.js' import { TaskOutput } from './task/TaskOutput.js' @@ -123,3 +124,42 @@ test('interrupt does not kill keep-alive commands used by asyncRewake hooks', as expect(result.code).toBe(2) expect(result.interrupted).toBe(false) }) + +test('already-aborted user-cancel kills a real child promptly', async () => { + const controller = new AbortController() + controller.abort('user-cancel') + + const startedAt = performance.now() + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }) + const childClosed = new Promise(resolve => { + child.once('close', () => resolve()) + }) + const command = wrapSpawn( + child, + controller.signal, + 1_000, + new TaskOutput('shellcommand-test-already-aborted', null), + ) + + try { + expect(child.pid).toBeDefined() + + const result = await command.result + const elapsedMs = performance.now() - startedAt + await childClosed + + expect(elapsedMs).toBeLessThan(2_000) + expect(result.isAbort).toBe(true) + expect(result.abortReason).toBe('user-abort') + expect(result.interrupted).toBe(true) + expect(result.signalAborted).toBe(true) + expect(command.status).toBe('killed') + } finally { + child.kill('SIGKILL') + await command.result + await childClosed + command.cleanup() + } +}) diff --git a/src/utils/ShellCommand.ts b/src/utils/ShellCommand.ts index 208226b396..96158db943 100644 --- a/src/utils/ShellCommand.ts +++ b/src/utils/ShellCommand.ts @@ -303,6 +303,9 @@ class ShellCommandImpl implements ShellCommand { this.#abortSignal.addEventListener('abort', this.#boundAbortHandler, { once: true, }) + if (this.#abortSignal.aborted) { + this.#boundAbortHandler() + } // Use 'exit' not 'close': 'close' waits for stdio to close, which includes // grandchild processes that inherit file descriptors (e.g. `sleep 30 &`).