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 <vincentkoc@ieee.org>
This commit is contained in:
Peter Lee
2026-08-05 03:53:44 -05:00
committed by GitHub
parent 26a58bcd92
commit 81238d0c0e
2 changed files with 72 additions and 1 deletions
@@ -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<Uint8Array>({
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<void> | undefined;
let cancellationSettled = false;
const cancellationStarted = new Promise<void>((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<symbol>((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);
}
});
});
+4 -1
View File
@@ -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);