perf(producer): stream binary file responses, async-read HTML

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 <noreply@anthropic.com>
This commit is contained in:
James
2026-06-26 15:04:47 +00:00
co-authored by Claude Opus 4.7
parent d70ee134cc
commit 831f580435
2 changed files with 85 additions and 6 deletions
@@ -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-"));
+28 -6
View File
@@ -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<FileServer
const app = new Hono();
app.get("/*", (c) => {
app.get("/*", async (c) => {
let requestPath = c.req.path;
if (requestPath === "/") requestPath = "/index.html";
@@ -665,7 +667,12 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
const contentType = MIME_TYPES[ext] || "application/octet-stream";
if (ext === ".html") {
const rawHtml = readFileSync(filePath, "utf-8");
// Use the async read here so we don't block the Node event loop while
// reading an HTML file (typically small, but a 200KB+ AI-generated
// composition during a concurrent render still costs a ms of stall).
// The injection step is sync — it's pure string ops on the buffered
// HTML — but the read itself is the only step that touches the disk.
const rawHtml = await readFile(filePath, "utf-8");
const isIndex = relativePath === "index.html";
let html = rawHtml;
if (preHeadScripts.length > 0) {
@@ -677,10 +684,25 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
return c.text(html, 200, { "Content-Type": contentType });
}
const content = readFileSync(filePath);
return new Response(content, {
// Stream binary file content rather than buffering it with readFileSync.
// On video-heavy compositions Chrome requests several 32MB video files
// back-to-back through this server; each readFileSync(32MB) blocked the
// Node event loop long enough to wedge concurrent /health responses (see
// renderOrchestrator.ts:1277-1306 documenting the same regression class).
// createReadStream() pipes bounded chunks asynchronously, so the event
// loop stays responsive even when several large assets are in flight
// simultaneously. Chrome reassembles the chunks transparently.
const stat = statSync(filePath);
const stream = createReadStream(filePath);
// Node Readable -> 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),
},
});
});