From 6dae06b26a0e37bce650f242ee6ef9dba0052b74 Mon Sep 17 00:00:00 2001 From: Misaka477 Date: Tue, 28 Jul 2026 00:03:51 +0800 Subject: [PATCH] fix: clean up coding bridge abort listener --- .../src/bridge/abort-race.test.ts | 36 +++++++++++++++++++ .../realtime-voice/src/bridge/abort-race.ts | 21 +++++++++++ .../src/bridge/coding-bridge.ts | 20 ++++------- 3 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 extensions/realtime-voice/src/bridge/abort-race.test.ts create mode 100644 extensions/realtime-voice/src/bridge/abort-race.ts diff --git a/extensions/realtime-voice/src/bridge/abort-race.test.ts b/extensions/realtime-voice/src/bridge/abort-race.test.ts new file mode 100644 index 00000000..1613ff7c --- /dev/null +++ b/extensions/realtime-voice/src/bridge/abort-race.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { raceWithAbort } from "./abort-race.js"; + +describe("raceWithAbort", () => { + it("removes the abort listener when work completes normally", async () => { + const controller = new AbortController(); + const removeEventListener = vi.spyOn( + controller.signal, + "removeEventListener", + ); + + await expect( + raceWithAbort( + controller.signal, + Promise.resolve("done"), + () => "aborted", + ), + ).resolves.toBe("done"); + + expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(removeEventListener).toHaveBeenCalledWith( + "abort", + expect.any(Function), + ); + }); + + it("returns the abort result when the signal aborts first", async () => { + const controller = new AbortController(); + const work = new Promise(() => {}); + const result = raceWithAbort(controller.signal, work, () => "aborted"); + + controller.abort(); + + await expect(result).resolves.toBe("aborted"); + }); +}); diff --git a/extensions/realtime-voice/src/bridge/abort-race.ts b/extensions/realtime-voice/src/bridge/abort-race.ts new file mode 100644 index 00000000..64b1b1da --- /dev/null +++ b/extensions/realtime-voice/src/bridge/abort-race.ts @@ -0,0 +1,21 @@ +export function raceWithAbort( + signal: AbortSignal, + work: Promise, + onAbort: () => T, +): Promise { + let removeListener: () => void = () => {}; + const aborted = new Promise((resolve) => { + const listener = () => { + removeListener(); + resolve(onAbort()); + }; + if (signal.aborted) { + listener(); + return; + } + signal.addEventListener("abort", listener, { once: true }); + removeListener = () => signal.removeEventListener("abort", listener); + }); + + return Promise.race([work, aborted]).finally(removeListener); +} diff --git a/extensions/realtime-voice/src/bridge/coding-bridge.ts b/extensions/realtime-voice/src/bridge/coding-bridge.ts index e8afe6d6..2750477c 100644 --- a/extensions/realtime-voice/src/bridge/coding-bridge.ts +++ b/extensions/realtime-voice/src/bridge/coding-bridge.ts @@ -12,6 +12,7 @@ import { type TaskFinalSummary, type TaskSnapshot, } from "@step-cli/realtime"; +import { raceWithAbort } from "./abort-race.js"; const log = logger.child({ component: "coding-bridge" }); @@ -129,21 +130,12 @@ export class CodingBridge { // throw on abort is not reliable (the subprocess can linger), and a // run() that never settles would leave currentTask stuck forever → // cancel appears to do nothing and no new task can be started. - const aborted = new Promise((resolve) => { - const onAbort = () => - resolve({ - status: "interrupted", - summary: "任务已取消", - detail: makeDetail(progress) as unknown as Record< - string, - unknown - >, - }); - if (ac.signal.aborted) onAbort(); - else ac.signal.addEventListener("abort", onAbort, { once: true }); - }); const work = this.runAgent(task, shouldResume, ac, progress, emit); - return Promise.race([work, aborted]); + return raceWithAbort(ac.signal, work, () => ({ + status: "interrupted", + summary: "任务已取消", + detail: makeDetail(progress) as unknown as Record, + })); }, }); }