From cef4aa6f133f1290aa673d46f6cdcb1d0c8790e1 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Mon, 29 Jun 2026 12:10:46 +0800
Subject: [PATCH] fix(runway): bound video create/poll response reads
Reviewed and accepted after live preflight: mergeable clean, checks passing, no unresolved review threads.
(cherry picked from commit 63b089383adf50be175322414166f57212cc34cc)
---
.../runway/video-generation-provider.test.ts | 94 +++++++++----------
.../runway/video-generation-provider.ts | 18 ++--
2 files changed, 53 insertions(+), 59 deletions(-)
diff --git a/extensions/runway/video-generation-provider.test.ts b/extensions/runway/video-generation-provider.test.ts
index a93d3f3747fa..37231749c5c4 100644
--- a/extensions/runway/video-generation-provider.test.ts
+++ b/extensions/runway/video-generation-provider.test.ts
@@ -63,6 +63,24 @@ function streamedVideoResponse(bytes: string): Response {
);
}
+// Response.json keeps object fixtures on the standard Response body path so create/poll
+// reads exercise the byte-bounded reader instead of an unbounded res.json().
+function streamedJsonResponse(payload: unknown): Response {
+ return Response.json(payload);
+}
+
+function streamedRawResponse(text: string): Response {
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(text));
+ controller.close();
+ },
+ }),
+ { headers: { "content-type": "application/json" } },
+ );
+}
+
describe("runway video generation provider", () => {
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildRunwayVideoGenerationProvider());
@@ -70,22 +88,19 @@ describe("runway video generation provider", () => {
it("submits a text-to-video task, polls it, and downloads the output", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({
- id: "task-1",
- }),
- },
+ response: streamedJsonResponse({
+ id: "task-1",
+ }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
- .mockResolvedValueOnce({
- json: async () => ({
+ .mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task-1",
status: "SUCCEEDED",
output: ["https://example.com/out.mp4"],
}),
- headers: new Headers(),
- })
+ )
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/webm" }),
@@ -130,20 +145,17 @@ describe("runway video generation provider", () => {
it("rejects generated video downloads that exceed the configured media cap", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task-too-large" }),
- },
+ response: streamedJsonResponse({ id: "task-too-large" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
- .mockResolvedValueOnce({
- json: async () => ({
+ .mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task-too-large",
status: "SUCCEEDED",
output: ["https://example.com/out.mp4"],
}),
- headers: new Headers(),
- })
+ )
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
const provider = buildRunwayVideoGenerationProvider();
@@ -159,20 +171,17 @@ describe("runway video generation provider", () => {
it("does not round malformed duration values into create requests", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task-duration" }),
- },
+ response: streamedJsonResponse({ id: "task-duration" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
- .mockResolvedValueOnce({
- json: async () => ({
+ .mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task-duration",
status: "SUCCEEDED",
output: ["https://example.com/out.mp4"],
}),
- headers: new Headers(),
- })
+ )
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
@@ -194,20 +203,17 @@ describe("runway video generation provider", () => {
it("accepts local image buffers by converting them into data URIs", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task-2" }),
- },
+ response: streamedJsonResponse({ id: "task-2" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
- .mockResolvedValueOnce({
- json: async () => ({
+ .mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task-2",
status: "SUCCEEDED",
output: ["https://example.com/out.mp4"],
}),
- headers: new Headers(),
- })
+ )
.mockResolvedValueOnce({
arrayBuffer: async () => Buffer.from("mp4-bytes"),
headers: new Headers({ "content-type": "video/mp4" }),
@@ -250,11 +256,7 @@ describe("runway video generation provider", () => {
it("reports malformed create JSON with a provider-owned error", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => {
- throw new SyntaxError("bad json");
- },
- },
+ response: streamedRawResponse("{ not json"),
release,
});
@@ -272,18 +274,15 @@ describe("runway video generation provider", () => {
it("rejects status responses missing a task status", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task-missing-status" }),
- },
+ response: streamedJsonResponse({ id: "task-missing-status" }),
release: vi.fn(async () => {}),
});
- fetchWithTimeoutMock.mockResolvedValueOnce({
- json: async () => ({
+ fetchWithTimeoutMock.mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task-missing-status",
output: ["https://example.com/out.mp4"],
}),
- headers: new Headers(),
- });
+ );
const provider = buildRunwayVideoGenerationProvider();
await expect(
@@ -298,19 +297,16 @@ describe("runway video generation provider", () => {
it("rejects malformed completed output URLs", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task-malformed-output" }),
- },
+ response: streamedJsonResponse({ id: "task-malformed-output" }),
release: vi.fn(async () => {}),
});
- fetchWithTimeoutMock.mockResolvedValueOnce({
- json: async () => ({
+ fetchWithTimeoutMock.mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task-malformed-output",
status: "SUCCEEDED",
output: "https://example.com/out.mp4",
}),
- headers: new Headers(),
- });
+ );
const provider = buildRunwayVideoGenerationProvider();
await expect(
diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts
index 59a63c8e485a..8b82a9111c5b 100644
--- a/extensions/runway/video-generation-provider.ts
+++ b/extensions/runway/video-generation-provider.ts
@@ -9,6 +9,7 @@ import {
fetchProviderDownloadResponse,
fetchProviderOperationResponse,
postJsonRequest,
+ readProviderJsonResponse,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
@@ -65,16 +66,13 @@ const VIDEO_MODELS = new Set(["gen4_aleph"]);
const RUNWAY_TEXT_ASPECT_RATIOS = ["16:9", "9:16"] as const;
const RUNWAY_EDIT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "21:9"] as const;
-async function readRunwayJsonResponse(
- response: Pick,
- label: string,
-): Promise {
- let payload: unknown;
- try {
- payload = await response.json();
- } catch (cause) {
- throw new Error(`${label}: malformed JSON response`, { cause });
- }
+async function readRunwayJsonResponse(response: Response, label: string): Promise {
+ // Runway submit/poll task bodies are read through the shared byte-bounded reader
+ // (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint
+ // that streams an unbounded JSON body cannot force the runtime to buffer the whole
+ // payload before parsing. Overflow cancels the stream and throws a bounded error;
+ // malformed JSON keeps the existing `${label}: malformed JSON response` wrapping.
+ const payload = await readProviderJsonResponse(response, label);
if (!isRecord(payload)) {
throw new Error(`${label}: malformed JSON response`);
}