Skip to content

Commit 30f548f

Browse files
fix: a stalled remote asset download no longer hangs a Cloud Run render (heygen-com#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
1 parent b1cf88a commit 30f548f

3 files changed

Lines changed: 55 additions & 2 deletions

File tree

‎packages/engine/src/utils/urlDownloader.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,55 @@ describe("downloadToTemp atomic publication and bounded retry", () => {
11291129
expect(temporaryDownloadEntries(dir)).toEqual([]);
11301130
});
11311131

1132+
it("keeps the deadline active when a stalled body never observes the abort", async () => {
1133+
// Bun 1.3.9's Readable.fromWeb can stop reading mid-body; the fetch abort then never
1134+
// reaches the pipeline. Model that with a body that neither ends nor errors on abort.
1135+
const fetchMock = vi
1136+
.fn()
1137+
.mockImplementationOnce(async () => {
1138+
const stalledBody = new ReadableStream<Uint8Array>({
1139+
start(controller) {
1140+
controller.enqueue(new TextEncoder().encode("partial"));
1141+
},
1142+
});
1143+
return new Response(stalledBody);
1144+
})
1145+
.mockResolvedValueOnce(new Response("complete"));
1146+
vi.stubGlobal("fetch", fetchMock);
1147+
const dir = makeTempDir();
1148+
1149+
const path = await downloadToTemp("https://cdn.example/unresponsive-body.mp4", dir, 20);
1150+
1151+
expect(fetchMock).toHaveBeenCalledTimes(2);
1152+
expect(readFileSync(path, "utf8")).toBe("complete");
1153+
expect(temporaryDownloadEntries(dir)).toEqual([]);
1154+
});
1155+
1156+
it("does not publish a chunked body that ended because the deadline aborted it", async () => {
1157+
// Bun 1.4.2 ends an aborted fetch body as if it were complete. With no Content-Length
1158+
// there is no length check to catch the truncation.
1159+
const fetchMock = vi
1160+
.fn()
1161+
.mockImplementationOnce(async (_url: string, init: RequestInit) => {
1162+
const truncatedBody = new ReadableStream<Uint8Array>({
1163+
start(controller) {
1164+
controller.enqueue(new TextEncoder().encode("partial"));
1165+
init.signal?.addEventListener("abort", () => controller.close(), { once: true });
1166+
},
1167+
});
1168+
return new Response(truncatedBody);
1169+
})
1170+
.mockResolvedValueOnce(new Response("complete"));
1171+
vi.stubGlobal("fetch", fetchMock);
1172+
const dir = makeTempDir();
1173+
1174+
const path = await downloadToTemp("https://cdn.example/aborted-chunked-body.mp4", dir, 20);
1175+
1176+
expect(fetchMock).toHaveBeenCalledTimes(2);
1177+
expect(readFileSync(path, "utf8")).toBe("complete");
1178+
expect(temporaryDownloadEntries(dir)).toEqual([]);
1179+
});
1180+
11321181
it("retries a zero-byte 200 response without publishing it", async () => {
11331182
const fetchMock = vi
11341183
.fn()

‎packages/engine/src/utils/urlDownloader.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -868,7 +868,11 @@ async function fetchToPartial(
868868
// eslint-disable-next-line @typescript-eslint/no-explicit-any
869869
const readableStream = Readable.fromWeb(response.body as any);
870870
const fileStream = createWriteStream(partialPath, { flags: "wx" });
871-
await pipeline(readableStream, inspector, fileStream);
871+
// The signal makes the attempt's deadline authoritative. Without it, the abort only
872+
// reaches the fetch, and the pipeline depends on the web-to-Node bridge to pass it on:
873+
// Bun 1.3.9's Readable.fromWeb can stop reading and never settle, and Bun 1.4.2's ends an
874+
// aborted body as if it were complete, which a chunked response would publish truncated.
875+
await pipeline(readableStream, inspector, fileStream, { signal: controller.signal });
872876
const localSize = statSync(partialPath).size;
873877
const sha256Bytes = sha256.digest();
874878
const localSha256 = sha256Bytes.toString("hex");

‎packages/gcp-cloud-run/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ WORKDIR /app
7878
# the container runs `bun dist/server.js` and relies on bun's ESM `require`
7979
# interop to load the producer bundle, so a future bun release changing that
8080
# behaviour shouldn't silently break the next image rebuild. Bump deliberately.
81-
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.9"
81+
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.2"
8282
ENV PATH="/root/.bun/bin:$PATH"
8383

8484
# Install workspace dependencies. Copy manifests first for layer caching.

0 commit comments

Comments
 (0)