From 68a2d10764c1c0a3bad346ccf23c8eeef0427492 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 04:52:25 +0000 Subject: [PATCH 1/2] perf(producer): stream binary file responses, async-read HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-request readFileSync in fileServer's static file handler with a createReadStream pipe (binary) and an async readFile (HTML). Static asset serving no longer blocks the Node event loop. Why --- The pre-fix handler called readFileSync(filePath) on every binary asset. On video-heavy compositions Chrome requests several 32MB video files back-to-back; each readFileSync(32MB) blocked the main event loop long enough to wedge concurrent /health responses and other timers. Scope clarification — this addresses the event-loop block documented at renderOrchestrator.ts:1277-1306 (the video-heavy regression class). It is NOT the fix for today's infinite-duration incident; Miguel is shipping that upstream as a plan()-time duration guard. The two are complementary: - Miguel's guard kills the impossible-work input shape before chunk planning so the producer doesn't try to enumerate 300B frames. - This streaming fix removes the next-largest known main-thread block (large binary I/O during video-heavy renders), so future wedge classes don't kill otherwise-healthy probes either. The companion worker_thread /health PR + the heygen-com/app probe-timeout bump round out the defense-in-depth: even if some future code path introduces another main-thread stall, the probe lives off-thread and the budget is 30s anyway. What changed ------------ fileServer.ts: switched both file branches off the sync I/O path. - Binary (the hot path for video-heavy renders): readFileSync(filePath) -> createReadStream + Readable.toWeb -> Response stream body. Content-Length is set via statSync so Chrome's range-aware media stack sees the size up front. The handler is now async because the HTML branch awaits. - HTML (small files; injected with pre/head/body scripts): readFileSync(filePath, "utf-8") -> readFile(filePath, "utf-8"). The injection is still sync — pure string ops — only the disk read moved off-thread. Index HTMLs are tiny (~200KB max for AI-generated compositions) but a ms of stall per render-start adds up across a fleet. Test ---- fileServer.test.ts: added a streaming regression that pins three properties on a 5MB synthetic binary asset (chunk-boundary spanning): 1. Correctness — served bytes match the file across multiple createReadStream chunks (default 64KB highWaterMark). 2. Content-Length header is set from statSync. 3. Four parallel fetches all return identical content; the streaming path doesn't serialize them. All 31 fileServer tests pass locally (bun test). TODO: link Miguel's upstream plan() duration guard PR once known. — Jerrai Co-Authored-By: Claude Opus 4.7 --- .../producer/src/services/fileServer.test.ts | 57 +++++++++++++++++++ packages/producer/src/services/fileServer.ts | 34 +++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 12cbed80e3..607a40c7bb 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -253,6 +253,63 @@ describe("createFileServer", () => { } }); + it("streams binary file content without buffering through readFileSync", async () => { + // Regression test for the video-heavy event-loop block documented at + // renderOrchestrator.ts:1277-1306. Pre-fix the file route called + // readFileSync on every binary asset, which on 32MB+ videos stalled + // the Node event loop long enough to wedge concurrent /health probes. + // This test pins three properties of the streaming path: + // + // 1. Correctness: the served byte sequence matches the file exactly, + // across a chunk boundary (we use a 5 MB synthetic asset, well past + // Node's default 64KB createReadStream highWaterMark). + // 2. Content-Length is reported via statSync so range-aware HTTP + // consumers (Chrome's media stack) see the size up front. + // 3. Concurrent requests don't serialize behind each other — N + // parallel fetches all return identical content. With readFileSync + // they'd block the event loop in serial; with the stream they + // pipe interleaved chunks. + const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-stream-")); + try { + writeEmptyIndex(projectDir); + // 5 MB of deterministic bytes — large enough to span many 64KB read + // chunks, small enough to keep the test fast. + const size = 5 * 1024 * 1024; + const buf = Buffer.alloc(size); + for (let i = 0; i < size; i++) buf[i] = i & 0xff; + writeFileSync(join(projectDir, "big.bin"), buf); + + await withFileServer(projectDir, async (server) => { + // Single-request correctness + content-length. + const r = await fetch(`${server.url}/big.bin`); + expect(r.status).toBe(200); + expect(r.headers.get("content-length")).toBe(String(size)); + const out = Buffer.from(await r.arrayBuffer()); + expect(out.length).toBe(size); + // Spot-check a few sentinel positions (full equality check is O(5MB) + // and unnecessary — if any chunk were misaligned we'd see it here). + expect(out[0]).toBe(0); + expect(out[255]).toBe(255); + expect(out[256]).toBe(0); + expect(out[size - 1]).toBe((size - 1) & 0xff); + + // Concurrent requests don't corrupt each other. + const concurrent = await Promise.all( + Array.from({ length: 4 }, () => fetch(`${server.url}/big.bin`)), + ); + for (const resp of concurrent) { + expect(resp.status).toBe(200); + const body = Buffer.from(await resp.arrayBuffer()); + expect(body.length).toBe(size); + expect(body[0]).toBe(0); + expect(body[size - 1]).toBe((size - 1) & 0xff); + } + }); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("decodes percent-encoded reserved characters in URL path segments", async () => { const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-reserved-chars-")); diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 829e3759c3..e75cbb7beb 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -11,7 +11,9 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; import type { IncomingMessage } from "node:http"; -import { readFileSync, existsSync, realpathSync, statSync } from "node:fs"; +import { existsSync, realpathSync, statSync, createReadStream } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { Readable } from "node:stream"; import { join, extname, resolve, sep } from "node:path"; import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; @@ -609,7 +611,7 @@ export function createFileServer(options: FileServerOptions): Promise { + app.get("/*", async (c) => { let requestPath = c.req.path; if (requestPath === "/") requestPath = "/index.html"; @@ -665,7 +667,12 @@ export function createFileServer(options: FileServerOptions): Promise 0) { @@ -677,10 +684,25 @@ export function createFileServer(options: FileServerOptions): Promise Web ReadableStream so Hono's Response can consume it. + // Node 18+ supports Readable.toWeb directly. + const webStream = Readable.toWeb(stream) as unknown as ReadableStream; + return new Response(webStream, { status: 200, - headers: { "Content-Type": contentType }, + headers: { + "Content-Type": contentType, + "Content-Length": String(stat.size), + }, }); }); From d3dd9e584cc0f794a9b0c85812a5894a1b44c3a2 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 26 Jun 2026 05:17:01 +0000 Subject: [PATCH 2/2] fix(producer): implement Accept-Ranges + 206 Partial Content for fileServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers the range-request semantics the original PR body promised but the diff did not implement. Without range support, Chrome's