diff --git a/src/app/v1/_lib/proxy/client-abort-listener.ts b/src/app/v1/_lib/proxy/client-abort-listener.ts index d47758907..7ebd24153 100644 --- a/src/app/v1/_lib/proxy/client-abort-listener.ts +++ b/src/app/v1/_lib/proxy/client-abort-listener.ts @@ -1,3 +1,9 @@ +/** + * 绑定客户端中止监听器,返回幂等清理函数。 + * + * 注意:如果传入的 signal 已经 aborted,会在当前调用栈同步执行 onAbort。 + * 调用方必须先初始化 onAbort 闭包会访问的控制器、任务 ID 等资源。 + */ export function bindClientAbortListener( signal: AbortSignal | null | undefined, onAbort: () => void diff --git a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts index c5dda43b9..af0dd96f7 100644 --- a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts +++ b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts @@ -110,7 +110,7 @@ function makeProvider(overrides: Partial = {}): Provider { return { id: 99, name: "test-provider", - providerType: "openai", + providerType: "openai-compatible", baseUrl: "https://api.test.invalid", priority: 1, weight: 1, @@ -166,7 +166,7 @@ function makeSession(clientAbortSignal: AbortSignal | null, stream: boolean): Pr sessionId: null, requestSequence: 1, originalFormat: "openai", - providerType: "openai", + providerType: "openai-compatible", originalModelName: "gpt-5.4", originalUrlPathname: "/v1/chat/completions", providerChain: [], @@ -280,4 +280,26 @@ describe("ProxyResponseHandler client abort listener cleanup", () => { expect(removeSpy.mock.calls.filter(([type]) => type === "abort")).toHaveLength(0); expect(testState.cancelTask).toHaveBeenCalled(); }); + + it("invokes stream cancel once when client signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const addSpy = vi.spyOn(controller.signal, "addEventListener"); + const removeSpy = vi.spyOn(controller.signal, "removeEventListener"); + const session = makeSession(controller.signal, true); + const upstreamResponse = new Response( + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', + { + headers: { "content-type": "text/event-stream" }, + } + ); + + const response = await ProxyResponseHandler.dispatch(session, upstreamResponse); + await response.text(); + await drainAsyncTasks(); + + expect(addSpy.mock.calls.filter(([type]) => type === "abort")).toHaveLength(0); + expect(removeSpy.mock.calls.filter(([type]) => type === "abort")).toHaveLength(0); + expect(testState.cancelTask).toHaveBeenCalledTimes(1); + }); });