From 30f548f62b0d1b46386aed0eb2cdeaacf84f491c Mon Sep 17 00:00:00 2001 From: Val Date: Wed, 23 Sep 2026 12:38:58 -0400 Subject: [PATCH] fix: a stalled remote asset download no longer hangs a Cloud Run render (#4343) * fix(gcp-cloud-run): run the Cloud Run image on Bun 1.4.2 so remote downloads cannot hang * fix(engine): make the download deadline abort the body pipeline --- .../engine/src/utils/urlDownloader.test.ts | 49 +++++++++++++++++++ packages/engine/src/utils/urlDownloader.ts | 6 ++- packages/gcp-cloud-run/Dockerfile | 2 +- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/utils/urlDownloader.test.ts b/packages/engine/src/utils/urlDownloader.test.ts index c2c1324eee..d44969bf28 100644 --- a/packages/engine/src/utils/urlDownloader.test.ts +++ b/packages/engine/src/utils/urlDownloader.test.ts @@ -1129,6 +1129,55 @@ describe("downloadToTemp atomic publication and bounded retry", () => { expect(temporaryDownloadEntries(dir)).toEqual([]); }); + it("keeps the deadline active when a stalled body never observes the abort", async () => { + // Bun 1.3.9's Readable.fromWeb can stop reading mid-body; the fetch abort then never + // reaches the pipeline. Model that with a body that neither ends nor errors on abort. + const fetchMock = vi + .fn() + .mockImplementationOnce(async () => { + const stalledBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + }, + }); + return new Response(stalledBody); + }) + .mockResolvedValueOnce(new Response("complete")); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp("https://cdn.example/unresponsive-body.mp4", dir, 20); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("does not publish a chunked body that ended because the deadline aborted it", async () => { + // Bun 1.4.2 ends an aborted fetch body as if it were complete. With no Content-Length + // there is no length check to catch the truncation. + const fetchMock = vi + .fn() + .mockImplementationOnce(async (_url: string, init: RequestInit) => { + const truncatedBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + init.signal?.addEventListener("abort", () => controller.close(), { once: true }); + }, + }); + return new Response(truncatedBody); + }) + .mockResolvedValueOnce(new Response("complete")); + vi.stubGlobal("fetch", fetchMock); + const dir = makeTempDir(); + + const path = await downloadToTemp("https://cdn.example/aborted-chunked-body.mp4", dir, 20); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + it("retries a zero-byte 200 response without publishing it", async () => { const fetchMock = vi .fn() diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index 874db947e0..85e2319f65 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -868,7 +868,11 @@ async function fetchToPartial( // eslint-disable-next-line @typescript-eslint/no-explicit-any const readableStream = Readable.fromWeb(response.body as any); const fileStream = createWriteStream(partialPath, { flags: "wx" }); - await pipeline(readableStream, inspector, fileStream); + // The signal makes the attempt's deadline authoritative. Without it, the abort only + // reaches the fetch, and the pipeline depends on the web-to-Node bridge to pass it on: + // Bun 1.3.9's Readable.fromWeb can stop reading and never settle, and Bun 1.4.2's ends an + // aborted body as if it were complete, which a chunked response would publish truncated. + await pipeline(readableStream, inspector, fileStream, { signal: controller.signal }); const localSize = statSync(partialPath).size; const sha256Bytes = sha256.digest(); const localSha256 = sha256Bytes.toString("hex"); diff --git a/packages/gcp-cloud-run/Dockerfile b/packages/gcp-cloud-run/Dockerfile index 6a7618cc6d..f30f18f01f 100644 --- a/packages/gcp-cloud-run/Dockerfile +++ b/packages/gcp-cloud-run/Dockerfile @@ -78,7 +78,7 @@ WORKDIR /app # the container runs `bun dist/server.js` and relies on bun's ESM `require` # interop to load the producer bundle, so a future bun release changing that # behaviour shouldn't silently break the next image rebuild. Bump deliberately. -RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.9" +RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.2" ENV PATH="/root/.bun/bin:$PATH" # Install workspace dependencies. Copy manifests first for layer caching.