diff --git a/extensions/qqbot/src/engine/api/media.test.ts b/extensions/qqbot/src/engine/api/media.test.ts index e88bb9e9e30c..0db5351e60a4 100644 --- a/extensions/qqbot/src/engine/api/media.test.ts +++ b/extensions/qqbot/src/engine/api/media.test.ts @@ -451,4 +451,72 @@ describe("MediaApi.uploadMedia direct URL uploads", () => { expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); expect(client["request"]).not.toHaveBeenCalled(); }); + + it("rejects promptly when a capture clone keeps body cancellation pending", async () => { + fetchWithSsrFGuardMock.mockReset(); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("server error")); + }, + }), + { status: 500 }, + ); + const captureClone = response.clone(); + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release }); + + const body = response.body!; + const originalCancel = body.cancel.bind(body); + let cancellation: Promise | undefined; + let cancellationSettled = false; + const cancellationStarted = new Promise((resolve) => { + vi.spyOn(body, "cancel").mockImplementation((reason) => { + cancellation = originalCancel(reason).finally(() => { + cancellationSettled = true; + }); + resolve(); + return cancellation; + }); + }); + + const client = mockApiClient(); + const tokenManager = mockTokenManager(); + const api = new MediaApi(client, tokenManager); + const upload = api.uploadMedia( + "c2c", + "user-openid", + MediaFileType.IMAGE, + { appId: "app-id", clientSecret: "client-secret" }, + { url: "https://cdn.example.com/server-error.png" }, + ); + const cancellationPending = Symbol("capture cancellation pending"); + + try { + await cancellationStarted; + expect(cancellationSettled).toBe(false); + + const result = await Promise.race([ + upload.then( + () => undefined, + (error: unknown) => error, + ), + new Promise((resolve) => { + setImmediate(() => resolve(cancellationPending)); + }), + ]); + + expect(result).not.toBe(cancellationPending); + expect(result).toMatchObject({ + message: "Direct-upload media URL returned HTTP 500", + }); + expect(release).toHaveBeenCalledOnce(); + expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); + expect(client["request"]).not.toHaveBeenCalled(); + } finally { + void captureClone.body?.cancel().catch(() => undefined); + await cancellation?.catch(() => undefined); + await upload.catch(() => undefined); + } + }); }); diff --git a/extensions/qqbot/src/engine/api/media.ts b/extensions/qqbot/src/engine/api/media.ts index 8c7a6f55b382..0f02dabb762d 100644 --- a/extensions/qqbot/src/engine/api/media.ts +++ b/extensions/qqbot/src/engine/api/media.ts @@ -161,7 +161,10 @@ export async function downloadDirectUploadUrl( const { response, release } = await fetchDirectUploadDownload(parsed.toString()); try { if (!response.ok) { - await response.body?.cancel().catch(() => undefined); + // A debug-capture clone can keep the tee open, so waiting for cancel would + // hang before the error is returned. Fire-and-forget matches the timeout + // path above and the pattern used across other plugins. + void response.body?.cancel().catch(() => undefined); throw new Error(`Direct-upload media URL returned HTTP ${response.status}`); } return await readDirectUploadResponse(response, opts.maxBytes ?? MAX_UPLOAD_SIZE);