From 5f4701c174e9612f40d0573adac985d42f89aba2 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Wed, 10 Jun 2026 11:24:05 -0700 Subject: [PATCH] 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 --- .../src/tools/shellBackgroundTools.test.ts | 25 +++++++++++++++++++ .../core/src/tools/shellBackgroundTools.ts | 9 +++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tools/shellBackgroundTools.test.ts b/packages/core/src/tools/shellBackgroundTools.test.ts index 363b5600ddc..34389fcc964 100644 --- a/packages/core/src/tools/shellBackgroundTools.test.ts +++ b/packages/core/src/tools/shellBackgroundTools.test.ts @@ -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' }); + }); }); diff --git a/packages/core/src/tools/shellBackgroundTools.ts b/packages/core/src/tools/shellBackgroundTools.ts index 00220b24fc6..f63b95958b4 100644 --- a/packages/core/src/tools/shellBackgroundTools.ts +++ b/packages/core/src/tools/shellBackgroundTools.ts @@ -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 { + async execute({ abortSignal }: ExecuteOptions): Promise { 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