diff --git a/assistant/src/__tests__/host-bash-proxy.test.ts b/assistant/src/__tests__/host-bash-proxy.test.ts index 1a0ac9009ae..03471bfb434 100644 --- a/assistant/src/__tests__/host-bash-proxy.test.ts +++ b/assistant/src/__tests__/host-bash-proxy.test.ts @@ -393,6 +393,152 @@ describe("HostBashProxy", () => { }); }); + describe("abort listener lifecycle", () => { + // Helper that wraps an AbortSignal to observe add/removeEventListener + // invocations without tripping over tsc's strict overload matching on + // AbortSignal itself. + type Spied = { + signal: AbortSignal; + addCalls: string[]; + removeCalls: string[]; + }; + function spySignal(source: AbortSignal): Spied { + const addCalls: string[] = []; + const removeCalls: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = source as any; + const origAdd = source.addEventListener.bind(source); + const origRemove = source.removeEventListener.bind(source); + s.addEventListener = ( + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ) => { + addCalls.push(type); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origAdd as any)(type, ...rest); + }; + s.removeEventListener = ( + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ) => { + removeCalls.push(type); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origRemove as any)(type, ...rest); + }; + return { signal: source, addCalls, removeCalls }; + } + + test("removes abort listener from signal after resolve completes", async () => { + setup(); + const controller = new AbortController(); + const spy = spySignal(controller.signal); + + const resultPromise = proxy.request( + { command: "echo hello" }, + "session-1", + spy.signal, + ); + + expect(spy.addCalls).toEqual(["abort"]); + expect(spy.removeCalls).toEqual([]); + + const requestId = (sentMessages[0] as Record) + .requestId as string; + proxy.resolve(requestId, { + stdout: "hello\n", + stderr: "", + exitCode: 0, + timedOut: false, + }); + await resultPromise; + + // Listener is detached after normal completion. + expect(spy.removeCalls).toEqual(["abort"]); + + // Subsequent aborts are harmless no-ops (no side effects on the proxy). + controller.abort(); + // No additional emitted envelopes from the late abort. + expect(sentMessages).toHaveLength(1); + }); + + test("removes abort listener from signal on timer timeout", async () => { + setup(); + + const controller = new AbortController(); + const spy = spySignal(controller.signal); + + // Use a negative timeout_seconds so that proxyTimeoutSec = -2.99 + 3 = 0.01s, + // causing the timer to fire quickly. + const resultPromise = proxy.request( + { command: "echo slow", timeout_seconds: -2.99 }, + "session-1", + spy.signal, + ); + + expect(spy.addCalls).toEqual(["abort"]); + expect(spy.removeCalls).toEqual([]); + + // Wait long enough for the timer (10ms) to fire. + await new Promise((r) => setTimeout(r, 50)); + + const result = await resultPromise; + expect(result.isError).toBe(true); + expect(result.content).toContain("Host bash proxy timed out"); + + // Listener is detached after the timer fires. + expect(spy.removeCalls).toEqual(["abort"]); + + // Subsequent aborts should be harmless — no cancel emitted. + controller.abort(); + expect(sentMessages).toHaveLength(1); + }); + }); + + describe("sender throws synchronously", () => { + test("rejects the promise, clears pending state and timer, invokes onInternalResolve", async () => { + const resolvedIds: string[] = []; + sentMessages = []; + sendToClient = () => { + throw new Error("transport down"); + }; + proxy = new HostBashProxy(sendToClient, (id) => resolvedIds.push(id)); + + // request() synchronously calls sendToClient inside the Promise + // executor. A throw there surfaces as a rejected promise. + const resultPromise = proxy.request( + { command: "echo hello" }, + "session-1", + ); + + await expect(resultPromise).rejects.toThrow("transport down"); + + // The internal resolve should fire exactly once as part of cleanup. + expect(resolvedIds).toHaveLength(1); + + // Issue a new request on a fresh (non-throwing) sender and verify + // the proxy is still functional — no stale timers or bookkeeping + // from the failed request. + sentMessages = []; + proxy.updateSender((msg) => sentMessages.push(msg), true); + const okPromise = proxy.request({ command: "echo ok" }, "session-1"); + expect(sentMessages).toHaveLength(1); + const okRequestId = (sentMessages[0] as Record) + .requestId as string; + expect(proxy.hasPendingRequest(okRequestId)).toBe(true); + proxy.resolve(okRequestId, { + stdout: "ok\n", + stderr: "", + exitCode: 0, + timedOut: false, + }); + const okResult = await okPromise; + expect(okResult.content).toContain("ok"); + expect(okResult.isError).toBe(false); + }); + }); + describe("onInternalResolve callback", () => { test("fires on abort", async () => { const resolvedIds: string[] = []; diff --git a/assistant/src/__tests__/host-cu-proxy.test.ts b/assistant/src/__tests__/host-cu-proxy.test.ts index 777ada9e4d0..7b0cf0d8b64 100644 --- a/assistant/src/__tests__/host-cu-proxy.test.ts +++ b/assistant/src/__tests__/host-cu-proxy.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, jest, test } from "bun:test"; import { HostCuProxy } from "../daemon/host-cu-proxy.js"; @@ -776,6 +776,176 @@ describe("HostCuProxy", () => { }); }); + // ------------------------------------------------------------------------- + // abort listener lifecycle + // ------------------------------------------------------------------------- + + describe("abort listener lifecycle", () => { + // Helper that wraps an AbortSignal to observe add/removeEventListener + // invocations without tripping over tsc's strict overload matching on + // AbortSignal itself. + type Spied = { + signal: AbortSignal; + addCalls: string[]; + removeCalls: string[]; + }; + function spySignal(source: AbortSignal): Spied { + const addCalls: string[] = []; + const removeCalls: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = source as any; + const origAdd = source.addEventListener.bind(source); + const origRemove = source.removeEventListener.bind(source); + s.addEventListener = ( + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ) => { + addCalls.push(type); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origAdd as any)(type, ...rest); + }; + s.removeEventListener = ( + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ) => { + removeCalls.push(type); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origRemove as any)(type, ...rest); + }; + return { signal: source, addCalls, removeCalls }; + } + + test("removes abort listener from signal after resolve completes", async () => { + setup(); + const controller = new AbortController(); + const spy = spySignal(controller.signal); + + const resultPromise = proxy.request( + "computer_use_click", + { element_id: 1 }, + "session-1", + 1, + undefined, + spy.signal, + ); + + expect(spy.addCalls).toEqual(["abort"]); + expect(spy.removeCalls).toEqual([]); + + const requestId = (sentMessages[0] as Record) + .requestId as string; + proxy.resolve(requestId, { axTree: "Button [1]" }); + await resultPromise; + + // Listener is detached after normal completion. + expect(spy.removeCalls).toEqual(["abort"]); + + // Subsequent aborts are harmless no-ops (no side effects on the proxy). + controller.abort(); + // No additional emitted envelopes from the late abort. + expect(sentMessages).toHaveLength(1); + }); + + test("removes abort listener from signal on timer timeout", async () => { + setup(); + + jest.useFakeTimers(); + try { + const controller = new AbortController(); + const spy = spySignal(controller.signal); + + const resultPromise = proxy.request( + "computer_use_click", + { element_id: 1 }, + "session-1", + 1, + undefined, + spy.signal, + ); + + expect(spy.addCalls).toEqual(["abort"]); + expect(spy.removeCalls).toEqual([]); + + const requestId = (sentMessages[0] as Record) + .requestId as string; + expect(proxy.hasPendingRequest(requestId)).toBe(true); + + // Advance past the 60s internal timeout. + jest.advanceTimersByTime(61 * 1000); + + const result = await resultPromise; + expect(result.isError).toBe(true); + expect(result.content).toContain("Host CU proxy timed out"); + expect(proxy.hasPendingRequest(requestId)).toBe(false); + + // Listener is detached after the timer fires. + expect(spy.removeCalls).toEqual(["abort"]); + + // Subsequent aborts should be harmless — no cancel emitted. + controller.abort(); + expect(sentMessages).toHaveLength(1); + } finally { + jest.useRealTimers(); + } + }); + }); + + // ------------------------------------------------------------------------- + // sender throws synchronously + // ------------------------------------------------------------------------- + + describe("sender throws synchronously", () => { + test("rejects the promise, clears pending state and timer, invokes onInternalResolve", async () => { + sentMessages = []; + resolvedRequestIds = []; + const throwingSend = () => { + throw new Error("transport down"); + }; + proxy = new HostCuProxy(throwingSend as never, (requestId: string) => + resolvedRequestIds.push(requestId), + ); + + // request() synchronously calls sendToClient inside the Promise + // executor. A throw there surfaces as a rejected promise. + const resultPromise = proxy.request( + "computer_use_click", + { element_id: 1 }, + "session-1", + 1, + ); + + await expect(resultPromise).rejects.toThrow("transport down"); + + // The internal resolve should fire exactly once as part of cleanup. + expect(resolvedRequestIds).toHaveLength(1); + + // Issue a new request on a fresh (non-throwing) sender and verify + // the proxy is still functional — no stale timers or bookkeeping + // from the failed request. + sentMessages = []; + proxy.updateSender( + ((msg: unknown) => sentMessages.push(msg)) as never, + true, + ); + const okPromise = proxy.request( + "computer_use_click", + { element_id: 2 }, + "session-1", + 2, + ); + expect(sentMessages).toHaveLength(1); + const okRequestId = (sentMessages[0] as Record) + .requestId as string; + expect(proxy.hasPendingRequest(okRequestId)).toBe(true); + proxy.resolve(okRequestId, { axTree: "Button [2]" }); + const okResult = await okPromise; + expect(okResult.isError).toBe(false); + expect(okResult.content).toContain("Button [2]"); + }); + }); + // ------------------------------------------------------------------------- // onInternalResolve callback // ------------------------------------------------------------------------- diff --git a/assistant/src/__tests__/host-file-proxy.test.ts b/assistant/src/__tests__/host-file-proxy.test.ts index b08626e9748..4fe9bc3d4fd 100644 --- a/assistant/src/__tests__/host-file-proxy.test.ts +++ b/assistant/src/__tests__/host-file-proxy.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, jest, test } from "bun:test"; const { HostFileProxy } = await import("../daemon/host-file-proxy.js"); @@ -377,6 +377,151 @@ describe("HostFileProxy", () => { }); }); + describe("abort listener lifecycle", () => { + // Helper that wraps an AbortSignal to observe add/removeEventListener + // invocations without tripping over tsc's strict overload matching on + // AbortSignal itself. + type Spied = { + signal: AbortSignal; + addCalls: string[]; + removeCalls: string[]; + }; + function spySignal(source: AbortSignal): Spied { + const addCalls: string[] = []; + const removeCalls: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = source as any; + const origAdd = source.addEventListener.bind(source); + const origRemove = source.removeEventListener.bind(source); + s.addEventListener = ( + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ) => { + addCalls.push(type); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origAdd as any)(type, ...rest); + }; + s.removeEventListener = ( + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ) => { + removeCalls.push(type); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (origRemove as any)(type, ...rest); + }; + return { signal: source, addCalls, removeCalls }; + } + + test("removes abort listener from signal after resolve completes", async () => { + setup(); + const controller = new AbortController(); + const spy = spySignal(controller.signal); + + const resultPromise = proxy.request( + { operation: "read", path: "/tmp/test.txt" }, + "session-1", + spy.signal, + ); + + expect(spy.addCalls).toEqual(["abort"]); + expect(spy.removeCalls).toEqual([]); + + const requestId = (sentMessages[0] as Record) + .requestId as string; + proxy.resolve(requestId, { content: "file contents", isError: false }); + await resultPromise; + + // Listener is detached after normal completion. + expect(spy.removeCalls).toEqual(["abort"]); + + // Subsequent aborts are harmless no-ops (no side effects on the proxy). + controller.abort(); + // No additional emitted envelopes from the late abort. + expect(sentMessages).toHaveLength(1); + }); + + test("removes abort listener from signal on timer timeout", async () => { + setup(); + + jest.useFakeTimers(); + try { + const controller = new AbortController(); + const spy = spySignal(controller.signal); + + const resultPromise = proxy.request( + { operation: "read", path: "/tmp/slow.txt" }, + "session-1", + spy.signal, + ); + + expect(spy.addCalls).toEqual(["abort"]); + expect(spy.removeCalls).toEqual([]); + + const requestId = (sentMessages[0] as Record) + .requestId as string; + expect(proxy.hasPendingRequest(requestId)).toBe(true); + + // Advance past the 30s internal timeout. + jest.advanceTimersByTime(31 * 1000); + + const result = await resultPromise; + expect(result.isError).toBe(true); + expect(result.content).toContain("Host file proxy timed out"); + expect(proxy.hasPendingRequest(requestId)).toBe(false); + + // Listener is detached after the timer fires. + expect(spy.removeCalls).toEqual(["abort"]); + + // Subsequent aborts should be harmless — no cancel emitted. + controller.abort(); + expect(sentMessages).toHaveLength(1); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe("sender throws synchronously", () => { + test("rejects the promise, clears pending state and timer, invokes onInternalResolve", async () => { + const resolvedIds: string[] = []; + sentMessages = []; + sendToClient = () => { + throw new Error("transport down"); + }; + proxy = new HostFileProxy(sendToClient, (id) => resolvedIds.push(id)); + + const resultPromise = proxy.request( + { operation: "read", path: "/tmp/test.txt" }, + "session-1", + ); + + await expect(resultPromise).rejects.toThrow("transport down"); + + // The internal resolve should fire exactly once as part of cleanup. + expect(resolvedIds).toHaveLength(1); + + // Issue a new request on a fresh (non-throwing) sender and verify + // the proxy is still functional — no stale timers or bookkeeping + // from the failed request. + sentMessages = []; + proxy.updateSender((msg) => sentMessages.push(msg), true); + const okPromise = proxy.request( + { operation: "read", path: "/tmp/ok.txt" }, + "session-1", + ); + expect(sentMessages).toHaveLength(1); + const okRequestId = (sentMessages[0] as Record) + .requestId as string; + expect(proxy.hasPendingRequest(okRequestId)).toBe(true); + proxy.resolve(okRequestId, { content: "ok", isError: false }); + const okResult = await okPromise; + expect(okResult.content).toBe("ok"); + expect(okResult.isError).toBe(false); + }); + }); + describe("onInternalResolve callback", () => { test("fires on abort", async () => { const resolvedIds: string[] = []; diff --git a/assistant/src/daemon/host-bash-proxy.ts b/assistant/src/daemon/host-bash-proxy.ts index 9f1d4a3706f..6345e5424ca 100644 --- a/assistant/src/daemon/host-bash-proxy.ts +++ b/assistant/src/daemon/host-bash-proxy.ts @@ -14,6 +14,8 @@ interface PendingRequest { reject: (err: Error) => void; timer: ReturnType; timeoutSec: number; + /** Detach the abort listener from the caller's signal. No-op when no signal was passed. */ + detachAbort: () => void; } export class HostBashProxy { @@ -60,8 +62,14 @@ export class HostBashProxy { const timeoutSec = input.timeout_seconds ?? shellMaxTimeoutSec; // Proxy timeout: slightly after client-side timeout, but before executor's outer timeout const proxyTimeoutSec = timeoutSec + 3; + + // Declared up-front so onAbort (defined before detachAbort is assigned) + // can close over a stable reference once it's wired below. + let detachAbort: () => void = () => {}; + const timer = setTimeout(() => { this.pending.delete(requestId); + detachAbort(); this.onInternalResolve?.(requestId); log.warn( { requestId, command: input.command }, @@ -78,13 +86,14 @@ export class HostBashProxy { ); }, proxyTimeoutSec * 1000); - this.pending.set(requestId, { resolve, reject, timer, timeoutSec }); - if (signal) { const onAbort = () => { if (this.pending.has(requestId)) { clearTimeout(timer); this.pending.delete(requestId); + // Abort fired — nothing to detach, but call the no-op for symmetry + // so callers can rely on detachAbort being idempotent. + detachAbort(); this.onInternalResolve?.(requestId); try { this.sendToClient({ @@ -98,19 +107,43 @@ export class HostBashProxy { } }; signal.addEventListener("abort", onAbort, { once: true }); + detachAbort = () => signal.removeEventListener("abort", onAbort); } - this.sendToClient({ - type: "host_bash_request", - requestId, - conversationId, - command: input.command, - working_dir: input.working_dir, - timeout_seconds: input.timeout_seconds, - ...(input.env && Object.keys(input.env).length > 0 - ? { env: input.env } - : {}), - } as ServerMessage); + this.pending.set(requestId, { + resolve, + reject, + timer, + timeoutSec, + detachAbort, + }); + + try { + this.sendToClient({ + type: "host_bash_request", + requestId, + conversationId, + command: input.command, + working_dir: input.working_dir, + timeout_seconds: input.timeout_seconds, + ...(input.env && Object.keys(input.env).length > 0 + ? { env: input.env } + : {}), + } as ServerMessage); + } catch (err) { + // Sender threw synchronously (e.g. client transport error during + // event emission). Clean up pending state and timer so we don't + // leak an in-flight entry that nothing will ever resolve. + clearTimeout(timer); + this.pending.delete(requestId); + detachAbort(); + this.onInternalResolve?.(requestId); + log.warn( + { requestId, command: input.command, err }, + "Host bash proxy send failed", + ); + reject(err instanceof Error ? err : new Error(String(err))); + } }); } @@ -129,6 +162,7 @@ export class HostBashProxy { return; } clearTimeout(entry.timer); + entry.detachAbort(); this.pending.delete(requestId); const result = formatShellOutput( response.stdout, @@ -151,6 +185,7 @@ export class HostBashProxy { dispose(): void { for (const [requestId, entry] of this.pending) { clearTimeout(entry.timer); + entry.detachAbort(); this.onInternalResolve?.(requestId); try { this.sendToClient({ diff --git a/assistant/src/daemon/host-cu-proxy.ts b/assistant/src/daemon/host-cu-proxy.ts index c35cba30ad3..68b0adeb16d 100644 --- a/assistant/src/daemon/host-cu-proxy.ts +++ b/assistant/src/daemon/host-cu-proxy.ts @@ -57,6 +57,8 @@ interface PendingRequest { resolve: (result: ToolExecutionResult) => void; reject: (err: Error) => void; timer: ReturnType; + /** Detach the abort listener from the caller's signal. No-op when no signal was passed. */ + detachAbort: () => void; } // --------------------------------------------------------------------------- @@ -152,8 +154,13 @@ export class HostCuProxy { const requestId = uuid(); return new Promise((resolve, reject) => { + // Declared up-front so onAbort (defined before detachAbort is assigned) + // can close over a stable reference once it's wired below. + let detachAbort: () => void = () => {}; + const timer = setTimeout(() => { this.pending.delete(requestId); + detachAbort(); this.onInternalResolve?.(requestId); log.warn({ requestId, toolName }, "Host CU proxy request timed out"); resolve({ @@ -162,13 +169,14 @@ export class HostCuProxy { }); }, REQUEST_TIMEOUT_SEC * 1000); - this.pending.set(requestId, { resolve, reject, timer }); - if (signal) { const onAbort = () => { if (this.pending.has(requestId)) { clearTimeout(timer); this.pending.delete(requestId); + // Abort fired — nothing to detach, but call the no-op for symmetry + // so callers can rely on detachAbort being idempotent. + detachAbort(); this.onInternalResolve?.(requestId); try { this.sendToClient({ @@ -182,17 +190,32 @@ export class HostCuProxy { } }; signal.addEventListener("abort", onAbort, { once: true }); + detachAbort = () => signal.removeEventListener("abort", onAbort); } - this.sendToClient({ - type: "host_cu_request", - requestId, - conversationId, - toolName, - input, - stepNumber, - reasoning, - } as ServerMessage); + this.pending.set(requestId, { resolve, reject, timer, detachAbort }); + + try { + this.sendToClient({ + type: "host_cu_request", + requestId, + conversationId, + toolName, + input, + stepNumber, + reasoning, + } as ServerMessage); + } catch (err) { + // Sender threw synchronously (e.g. client transport error during + // event emission). Clean up pending state and timer so we don't + // leak an in-flight entry that nothing will ever resolve. + clearTimeout(timer); + this.pending.delete(requestId); + detachAbort(); + this.onInternalResolve?.(requestId); + log.warn({ requestId, toolName, err }, "Host CU proxy send failed"); + reject(err instanceof Error ? err : new Error(String(err))); + } }); } @@ -203,6 +226,7 @@ export class HostCuProxy { return; } clearTimeout(entry.timer); + entry.detachAbort(); this.pending.delete(requestId); // Capture pre-update state so formatObservation sees the correct previous AX tree @@ -388,6 +412,7 @@ export class HostCuProxy { dispose(): void { for (const [requestId, entry] of this.pending) { clearTimeout(entry.timer); + entry.detachAbort(); this.onInternalResolve?.(requestId); try { this.sendToClient({ diff --git a/assistant/src/daemon/host-file-proxy.ts b/assistant/src/daemon/host-file-proxy.ts index d0d2a4062c4..21b2eaea3df 100644 --- a/assistant/src/daemon/host-file-proxy.ts +++ b/assistant/src/daemon/host-file-proxy.ts @@ -23,6 +23,8 @@ interface PendingRequest { resolve: (result: ToolExecutionResult) => void; reject: (err: Error) => void; timer: ReturnType; + /** Detach the abort listener from the caller's signal. No-op when no signal was passed. */ + detachAbort: () => void; } export class HostFileProxy { @@ -61,8 +63,14 @@ export class HostFileProxy { return new Promise((resolve, reject) => { // File operations should be fast — 30 second timeout. const timeoutSec = 30; + + // Declared up-front so onAbort (defined before detachAbort is assigned) + // can close over a stable reference once it's wired below. + let detachAbort: () => void = () => {}; + const timer = setTimeout(() => { this.pending.delete(requestId); + detachAbort(); this.onInternalResolve?.(requestId); log.warn( { requestId, operation: input.operation }, @@ -74,13 +82,14 @@ export class HostFileProxy { }); }, timeoutSec * 1000); - this.pending.set(requestId, { resolve, reject, timer }); - if (signal) { const onAbort = () => { if (this.pending.has(requestId)) { clearTimeout(timer); this.pending.delete(requestId); + // Abort fired — nothing to detach, but call the no-op for symmetry + // so callers can rely on detachAbort being idempotent. + detachAbort(); this.onInternalResolve?.(requestId); try { this.sendToClient({ @@ -94,14 +103,32 @@ export class HostFileProxy { } }; signal.addEventListener("abort", onAbort, { once: true }); + detachAbort = () => signal.removeEventListener("abort", onAbort); } - this.sendToClient({ - ...input, - type: "host_file_request", - requestId, - conversationId, - } as ServerMessage); + this.pending.set(requestId, { resolve, reject, timer, detachAbort }); + + try { + this.sendToClient({ + ...input, + type: "host_file_request", + requestId, + conversationId, + } as ServerMessage); + } catch (err) { + // Sender threw synchronously (e.g. client transport error during + // event emission). Clean up pending state and timer so we don't + // leak an in-flight entry that nothing will ever resolve. + clearTimeout(timer); + this.pending.delete(requestId); + detachAbort(); + this.onInternalResolve?.(requestId); + log.warn( + { requestId, operation: input.operation, err }, + "Host file proxy send failed", + ); + reject(err instanceof Error ? err : new Error(String(err))); + } }); } @@ -115,6 +142,7 @@ export class HostFileProxy { return; } clearTimeout(entry.timer); + entry.detachAbort(); this.pending.delete(requestId); entry.resolve({ content: response.content, isError: response.isError }); } @@ -130,6 +158,7 @@ export class HostFileProxy { dispose(): void { for (const [requestId, entry] of this.pending) { clearTimeout(entry.timer); + entry.detachAbort(); this.onInternalResolve?.(requestId); try { this.sendToClient({