mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user