mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
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:
@@ -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-"));
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user