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
This commit is contained in:
Val
2026-09-23 16:38:58 +00:00
committed by GitHub
parent b1cf88aeba
commit 30f548f62b
3 changed files with 55 additions and 2 deletions
@@ -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<Uint8Array>({
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<Uint8Array>({
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()
+5 -1
View File
@@ -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");
+1 -1
View File
@@ -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.