fix(core): make read_background_output delay abort-aware

Pressing ESC during a read_background_output call with delay_ms left the
scheduler blocked until the timer fired, since the sleep ignored the abort
signal. Use the abortable timers/promises setTimeout so cancellation
rejects immediately with an AbortError, which the tool executor already
converts into a Cancelled result.

Fixes #18007
This commit is contained in:
Sandy Tao
2026-06-10 11:24:05 -07:00
parent 5d4af9f812
commit 5f4701c174
2 changed files with 32 additions and 2 deletions
@@ -331,4 +331,29 @@ describe('Background Tools', () => {
fs.unlinkSync(logPath);
});
it('read_background_output should abort the delay_ms wait when the signal is aborted', async () => {
const invocation = readTool.build({ pid: 12345, delay_ms: 60_000 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation as any).context = { config: { getSessionId: () => 'default' } };
const controller = new AbortController();
const promise = invocation.execute({ abortSignal: controller.signal });
controller.abort();
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
});
it('read_background_output should reject immediately when the signal is already aborted', async () => {
const invocation = readTool.build({ pid: 12345, delay_ms: 60_000 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation as any).context = { config: { getSessionId: () => 'default' } };
const controller = new AbortController();
controller.abort();
await expect(
invocation.execute({ abortSignal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
});
});
@@ -5,6 +5,7 @@
*/
import fs from 'node:fs';
import { setTimeout as delay } from 'node:timers/promises';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import {
BaseDeclarativeTool,
@@ -130,11 +131,15 @@ class ReadBackgroundOutputInvocation extends BaseToolInvocation<
return `Reading output for background process ${this.params.pid}`;
}
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
async execute({ abortSignal }: ExecuteOptions): Promise<ToolResult> {
const pid = this.params.pid;
if (this.params.delay_ms && this.params.delay_ms > 0) {
await new Promise((resolve) => setTimeout(resolve, this.params.delay_ms));
// Abort-aware delay: rejects with an AbortError when the user cancels,
// which the tool executor converts into a Cancelled result. Without
// this, cancellation would leave the scheduler blocked until the
// timer fires.
await delay(this.params.delay_ms, undefined, { signal: abortSignal });
}
// Verify process belongs to this session to prevent reading logs of processes from other sessions/users