fix(media): release rejected BytePlus and Runway video downloads under debug capture (#119257)

…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>
This commit is contained in:
Yiğit ERDOĞAN
2026-08-05 01:48:10 +03:00
committed by GitHub
parent c81aee5a96
commit 0d18bde848
4 changed files with 114 additions and 2 deletions
@@ -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<typeof setTimeout> | undefined;
try {
await expect(
Promise.race([
buildBytePlusVideoGenerationProvider().generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "captured invalid response",
cfg: {},
}),
new Promise<never>((_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" }),
@@ -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";
@@ -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<typeof setTimeout> | undefined;
try {
await expect(
Promise.race([
buildRunwayVideoGenerationProvider().generateVideo({
provider: "runway",
model: "gen4.5",
prompt: "captured invalid response",
cfg: {},
}),
new Promise<never>((_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" }),
@@ -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";