From 43a90ad868acd3eb06fea2c03830d51ae6618ec6 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 21 Oct 2026 17:29:12 -0700 Subject: [PATCH 1/2] fix(shell): honor an abort that lands before the listener is attached --- src/utils/ShellCommand.test.ts | 38 ++++++++++++++++++++++++++++++++++ src/utils/ShellCommand.ts | 3 +++ 2 files changed, 41 insertions(+) diff --git a/src/utils/ShellCommand.test.ts b/src/utils/ShellCommand.test.ts index 03f5e87f87..214a65c28e 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,40 @@ 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('sleep', ['5'], { 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 &`). From 13319ad29b73be9e242fd1b6351c22d3a10609f9 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 21 Oct 2026 17:47:37 -0700 Subject: [PATCH 2/2] test: spawn node timer instead of sleep for Windows portability --- src/utils/ShellCommand.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/ShellCommand.test.ts b/src/utils/ShellCommand.test.ts index 214a65c28e..306327bef0 100644 --- a/src/utils/ShellCommand.test.ts +++ b/src/utils/ShellCommand.test.ts @@ -130,7 +130,9 @@ test('already-aborted user-cancel kills a real child promptly', async () => { controller.abort('user-cancel') const startedAt = performance.now() - const child = spawn('sleep', ['5'], { stdio: 'ignore' }) + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }) const childClosed = new Promise(resolve => { child.once('close', () => resolve()) })