fix(producer): guard fileServer.close in all render cleanup paths (#1406)

* fix(producer): guard fileServer.close in distributed cleanup paths

`plan()` and `renderChunk()` both close the probe/chunk file server with a
bare `fileServer.close()` in their cleanup sequence. `FileServerHandle.close`
tears down the underlying http.Server, whose `close()` throws
`ERR_SERVER_NOT_RUNNING` if the server was already torn down (for example a
cancellation path that closed it once already). An unguarded throw there
escapes the cleanup and masks the original plan/render result, exactly the
failure the adjacent probe-session close already guards against with a
try/catch (its comment even spells this out).

Add `closeFileServerSafely`, which wraps the close in a try/catch and logs,
and route both cleanup sites through it so the two stay consistent and a
throwing close can never mask the real result. Covered by unit tests for
both the throwing and happy paths.

* fix(producer): extend fileServer.close guard to renderOrchestrator success path

---------

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This commit is contained in:
Carlos Alcaraz Gregor
2026-06-15 17:51:32 -07:00
committed by GitHub
co-authored by Carlos Alcaraz
parent 8f71378185
commit 1f9c70cf15
5 changed files with 76 additions and 4 deletions
@@ -38,6 +38,7 @@ import { join, relative, sep } from "node:path";
import { type CanvasResolution } from "@hyperframes/core";
import { type EngineConfig, getEncoderPreset, resolveConfig } from "@hyperframes/engine";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { closeFileServerSafely } from "../fileServer.js";
import { runAudioStage } from "../render/stages/audioStage.js";
import { runCompileStage } from "../render/stages/compileStage.js";
import { runExtractVideosStage } from "../render/stages/extractVideosStage.js";
@@ -833,7 +834,7 @@ export async function plan(
job.duration = probeResult.duration;
job.totalFrames = probeResult.totalFrames;
const totalFrames = probeResult.totalFrames;
if (probeResult.fileServer) probeResult.fileServer.close();
if (probeResult.fileServer) closeFileServerSafely(probeResult.fileServer, "plan", log);
if (probeResult.probeSession) {
// Close inside a try/catch — leaking a Chrome process here would mask
// the original plan() result on cancellation paths.
@@ -65,7 +65,12 @@ import {
} from "../render/stages/freezePlan.js";
import { sha256Hex } from "../render/stages/planHash.js";
import { applyRuntimeEnvSnapshot } from "../render/runtimeEnvSnapshot.js";
import { buildVirtualTimeShim, createFileServer, type FileServerHandle } from "../fileServer.js";
import {
buildVirtualTimeShim,
closeFileServerSafely,
createFileServer,
type FileServerHandle,
} from "../fileServer.js";
import {
buildSyntheticRenderJob,
type DistributedFormat,
@@ -654,7 +659,7 @@ export async function renderChunk(
});
}
}
fileServer.close();
closeFileServerSafely(fileServer, "renderChunk", log);
// Leave the temp work dir on failure (helps debugging); remove it on
// success below.
}
@@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node
import path, { join } from "node:path";
import { tmpdir } from "node:os";
import {
closeFileServerSafely,
createFileServer,
HF_BRIDGE_SCRIPT,
HF_EARLY_STUB,
@@ -11,6 +12,45 @@ import {
VIRTUAL_TIME_SHIM,
} from "./fileServer.js";
function captureLogger() {
const warnings: { message: string; meta?: Record<string, unknown> }[] = [];
return {
warnings,
log: {
error() {},
warn(message: string, meta?: Record<string, unknown>) {
warnings.push({ message, meta });
},
info() {},
debug() {},
},
};
}
describe("closeFileServerSafely", () => {
it("swallows and logs a throwing close instead of propagating", () => {
const { log, warnings } = captureLogger();
const fileServer = {
close: () => {
// http.Server.close() throws ERR_SERVER_NOT_RUNNING on a second close.
throw new Error("Server is not running.");
},
};
expect(() => closeFileServerSafely(fileServer, "plan", log)).not.toThrow();
expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain("[plan]");
expect(warnings[0].meta?.error).toBe("Server is not running.");
});
it("closes once and stays quiet on the happy path", () => {
const { log, warnings } = captureLogger();
let closed = 0;
closeFileServerSafely({ close: () => closed++ }, "renderChunk", log);
expect(closed).toBe(1);
expect(warnings).toHaveLength(0);
});
});
describe("injectScriptsIntoHtml", () => {
it("injects the virtual time shim into head content before authored scripts", () => {
const html = `<!DOCTYPE html>
@@ -16,6 +16,7 @@ import { join, extname, resolve, sep } from "node:path";
import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js";
import { defaultLogger, type ProducerLogger } from "../logger.js";
export { injectScriptsAtHeadStart };
@@ -558,6 +559,30 @@ export interface FileServerHandle {
addPreHeadScript: (script: string) => void;
}
/**
* Close a file server handle, swallowing and logging any error.
*
* `FileServerHandle.close` tears down the underlying http.Server, whose
* `close()` throws `ERR_SERVER_NOT_RUNNING` if the server is already torn down
* (for example a cancellation path that closed it once already). An unguarded
* throw inside a cleanup or `finally` block would mask the original render or
* plan result, so cleanup callers must go through this instead of calling
* `close()` directly.
*/
export function closeFileServerSafely(
fileServer: Pick<FileServerHandle, "close">,
label: string,
log: ProducerLogger = defaultLogger,
): void {
try {
fileServer.close();
} catch (err) {
log.warn(`[${label}] file server close failed`, {
error: err instanceof Error ? err.message : String(err),
});
}
}
export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
@@ -70,6 +70,7 @@ import { join, dirname, resolve } from "path";
import { randomUUID } from "crypto";
import { fileURLToPath } from "url";
import {
closeFileServerSafely,
createFileServer,
type FileServerHandle,
HF_PAGE_SIDE_COMPOSITING_STUB,
@@ -1567,7 +1568,7 @@ export async function executeRenderJob(
if (frameLookup) frameLookup.cleanup();
// Stop file server
fileServer.close();
closeFileServerSafely(fileServer, "renderOrchestrator", log);
fileServer = null;
// ── Stage 6: Assemble ───────────────────────────────────────────────