From 0d18bde84859ec69eba1209fce5fea0ee042ff93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Wed, 5 Aug 2026 01:48:10 +0300 Subject: [PATCH] fix(media): release rejected BytePlus and Runway video downloads under debug capture (#119257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …r debug capture When the debug proxy is enabled it patches global fetch and clones every response, so the caller-facing body is one branch of a live tee. Cancelling such a branch settles only after both branches cancel, so awaiting it in the rejected-download path leaves the download pending instead of surfacing the malformed-response error. Both providers read the completed media URL through the global fetch, so they reach the capture clone. Switch them to the fire-and-forget release the other generated-media owners already use, and cover each with a regression that keeps a capture clone live while the download is rejected. Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- .../video-generation-provider.test.ts | 54 +++++++++++++++++++ .../byteplus/video-generation-provider.ts | 4 +- .../runway/video-generation-provider.test.ts | 54 +++++++++++++++++++ .../runway/video-generation-provider.ts | 4 +- 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index 102d3b820433..5fc39de412c9 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -337,6 +337,60 @@ describe("byteplus video generation provider", () => { expect(canceled).toHaveBeenCalledOnce(); }); + it("releases a rejected download body without awaiting a debug-capture tee branch", async () => { + postJsonRequestMock.mockResolvedValue({ + response: streamedJsonResponse({ id: "task-captured-response" }), + release: vi.fn(async () => {}), + }); + // The debug proxy clones every captured response, so the caller-facing body is one + // branch of a live tee. Cancelling such a branch settles only once both branches + // cancel, so awaiting it here would hang the download instead of surfacing the error. + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}')); + }, + }), + { headers: { "content-type": "application/json" } }, + ); + const captureClone = response.clone(); + const captureReader = captureClone.body?.getReader(); + await captureReader?.read(); + fetchWithTimeoutMock + .mockResolvedValueOnce( + streamedJsonResponse({ + id: "task-captured-response", + status: "succeeded", + content: { video_url: "https://example.com/invalid.mp4" }, + }), + ) + .mockResolvedValueOnce(response); + + let timeout: ReturnType | undefined; + try { + await expect( + Promise.race([ + buildBytePlusVideoGenerationProvider().generateVideo({ + provider: "byteplus", + model: "seedance-1-0-pro-250528", + prompt: "captured invalid response", + cfg: {}, + }), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error("BytePlus download waited for a captured response clone")); + }, 500); + }), + ]), + ).rejects.toThrow("BytePlus generated video download: malformed video response"); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + await captureReader?.cancel().catch(() => undefined); + } + }); + it("rejects generated video downloads that exceed the configured media cap", async () => { postJsonRequestMock.mockResolvedValue({ response: streamedJsonResponse({ id: "task_too_large" }), diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index 7aeaa700d80a..1222b1e42e49 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -201,7 +201,9 @@ async function downloadBytePlusVideo(params: { assertProviderBinaryResponseContent(response, "BytePlus generated video download", "video"); } catch (error) { // A rejected binary response still owns a live socket until its unread body is canceled. - await response.body?.cancel().catch(() => undefined); + // A debug-capture clone can keep the tee open, so waiting for cancel would hang + // before the rejected response and its dispatcher can be released. + void response.body?.cancel().catch(() => undefined); throw error; } const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts index e3594d8bd970..2c40107a1618 100644 --- a/extensions/runway/video-generation-provider.test.ts +++ b/extensions/runway/video-generation-provider.test.ts @@ -210,6 +210,60 @@ describe("runway video generation provider", () => { expect(canceled).toHaveBeenCalledOnce(); }); + it("releases a rejected download body without awaiting a debug-capture tee branch", async () => { + postJsonRequestMock.mockResolvedValue({ + response: streamedJsonResponse({ id: "task-captured-response" }), + release: vi.fn(async () => {}), + }); + // The debug proxy clones every captured response, so the caller-facing body is one + // branch of a live tee. Cancelling such a branch settles only once both branches + // cancel, so awaiting it here would hang the download instead of surfacing the error. + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}')); + }, + }), + { headers: { "content-type": "application/json" } }, + ); + const captureClone = response.clone(); + const captureReader = captureClone.body?.getReader(); + await captureReader?.read(); + fetchWithTimeoutMock + .mockResolvedValueOnce( + streamedJsonResponse({ + id: "task-captured-response", + status: "SUCCEEDED", + output: ["https://example.com/invalid.mp4"], + }), + ) + .mockResolvedValueOnce(response); + + let timeout: ReturnType | undefined; + try { + await expect( + Promise.race([ + buildRunwayVideoGenerationProvider().generateVideo({ + provider: "runway", + model: "gen4.5", + prompt: "captured invalid response", + cfg: {}, + }), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error("Runway download waited for a captured response clone")); + }, 500); + }), + ]), + ).rejects.toThrow("Runway generated video download: malformed video response"); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + await captureReader?.cancel().catch(() => undefined); + } + }); + it("rejects generated video downloads that exceed the configured media cap", async () => { postJsonRequestMock.mockResolvedValue({ response: streamedJsonResponse({ id: "task-too-large" }), diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index 97998748eb65..fb71db8ec317 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -320,7 +320,9 @@ async function downloadRunwayVideos(params: { assertProviderBinaryResponseContent(response, "Runway generated video download", "video"); } catch (error) { // A rejected binary response still owns a live socket until its unread body is canceled. - await response.body?.cancel().catch(() => undefined); + // A debug-capture clone can keep the tee open, so waiting for cancel would hang + // before the rejected response and its dispatcher can be released. + void response.body?.cancel().catch(() => undefined); throw error; } const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4";