diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 12cbed80e3..979bc268e5 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -9,6 +9,7 @@ import { HF_EARLY_STUB, injectScriptsAtHeadStart, isPathInside, + parseRangeHeader, VIRTUAL_TIME_SHIM, } from "./fileServer.js"; @@ -225,6 +226,94 @@ describe("isPathInside", () => { }); }); +describe("parseRangeHeader", () => { + const SIZE = 1000; + + it("returns absent when there is no Range header", () => { + expect(parseRangeHeader(undefined, SIZE)).toEqual({ kind: "absent" }); + expect(parseRangeHeader(null, SIZE)).toEqual({ kind: "absent" }); + expect(parseRangeHeader("", SIZE)).toEqual({ kind: "absent" }); + }); + + it("parses a closed range bytes=START-END", () => { + expect(parseRangeHeader("bytes=0-99", SIZE)).toEqual({ + kind: "satisfiable", + start: 0, + end: 99, + }); + expect(parseRangeHeader("bytes=100-199", SIZE)).toEqual({ + kind: "satisfiable", + start: 100, + end: 199, + }); + }); + + it("parses an open-ended range bytes=START- as start..EOF", () => { + expect(parseRangeHeader("bytes=100-", SIZE)).toEqual({ + kind: "satisfiable", + start: 100, + end: SIZE - 1, + }); + expect(parseRangeHeader("bytes=0-", SIZE)).toEqual({ + kind: "satisfiable", + start: 0, + end: SIZE - 1, + }); + }); + + it("parses a suffix range bytes=-N as the last N bytes", () => { + expect(parseRangeHeader("bytes=-50", SIZE)).toEqual({ + kind: "satisfiable", + start: SIZE - 50, + end: SIZE - 1, + }); + // Suffix larger than the file: clamp to the whole file. + expect(parseRangeHeader("bytes=-5000", SIZE)).toEqual({ + kind: "satisfiable", + start: 0, + end: SIZE - 1, + }); + }); + + it("clamps the end of a closed range to the last valid byte", () => { + // bytes=900-9999 on a 1000-byte file -> serve 900..999. + expect(parseRangeHeader("bytes=900-9999", SIZE)).toEqual({ + kind: "satisfiable", + start: 900, + end: SIZE - 1, + }); + }); + + it("returns unsatisfiable when start >= size", () => { + expect(parseRangeHeader("bytes=1000-2000", SIZE)).toEqual({ kind: "unsatisfiable" }); + expect(parseRangeHeader("bytes=2000-", SIZE)).toEqual({ kind: "unsatisfiable" }); + }); + + it("returns unsatisfiable when end < start in a closed range", () => { + expect(parseRangeHeader("bytes=200-100", SIZE)).toEqual({ kind: "unsatisfiable" }); + }); + + it("returns unsatisfiable for a suffix request on a zero-byte file", () => { + expect(parseRangeHeader("bytes=-10", 0)).toEqual({ kind: "unsatisfiable" }); + }); + + it("returns absent for non-bytes units, multi-range, and malformed inputs", () => { + expect(parseRangeHeader("items=0-1", SIZE)).toEqual({ kind: "absent" }); + expect(parseRangeHeader("bytes=0-99,200-299", SIZE)).toEqual({ kind: "absent" }); + expect(parseRangeHeader("bytes=abc-def", SIZE)).toEqual({ kind: "absent" }); + expect(parseRangeHeader("bytes=", SIZE)).toEqual({ kind: "absent" }); + expect(parseRangeHeader("bytes=-", SIZE)).toEqual({ kind: "absent" }); + }); + + it("tolerates surrounding whitespace and case", () => { + expect(parseRangeHeader(" Bytes = 0-99 ", SIZE)).toEqual({ + kind: "satisfiable", + start: 0, + end: 99, + }); + }); +}); + describe("createFileServer", () => { it("serves asset files through project-root symlinked directories", async () => { const workspaceDir = mkdtempSync(join(tmpdir(), "hf-file-server-symlink-assets-")); @@ -253,6 +342,150 @@ 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("serves Range requests with 206 Partial Content + Accept-Ranges", async () => { + // Pins the RFC 7233 implementation for the binary path: Chrome's