From 81238d0c0ec599d141304bbcd79b742f80458104 Mon Sep 17 00:00:00 2001
From: Peter Lee
Date: Wed, 5 Aug 2026 03:53:44 -0500
Subject: [PATCH] fix(qqbot): reject open HTTP error bodies during debug
capture (#119466)
* fix(qqbot): cancel rejected direct-upload body fire-and-forget under debug proxy
* test(qqbot): prove capture tee cancellation stays non-blocking
Punchcard-Session: clear-orchard-lantern-bc
---------
Co-authored-by: Vincent Koc
---
extensions/qqbot/src/engine/api/media.test.ts | 68 +++++++++++++++++++
extensions/qqbot/src/engine/api/media.ts | 5 +-
2 files changed, 72 insertions(+), 1 deletion(-)
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);