From 96184871a3ac776f7a29b2881524788a889c67e1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 18 Oct 2026 15:44:59 +0100 Subject: [PATCH] fix(frontdoor): retry preferred port on bind and check log existence with stat When a supervisor process restarts or is replaced, the outgoing process releases its listening ports over a brief window while shutting down its servers. Previously, listenFrontDoor made only a single bind attempt on preferredPort; if the socket was still in TIME_WAIT or closing, it immediately fell back to port 0 (an ephemeral port). This changed the sticky ports in state.json and severed all running Claude Code child sessions whose HTTPS_PROXY pointed to the former port. listenFrontDoor now retries binding preferredPort with a short backoff window (up to 250ms) before falling back to an ephemeral port, giving the releasing supervisor time to free the address and preserving port stickiness. Additionally, collectHeadroomStatus, collectFrontDoorStatus, and collectCodexStatus checked logExists by reading the entire log file as a UTF-8 string into memory. Once a log file (such as headroom.log) grew past Node's 512MB V8 string length limit, readFileUtf8 threw ERR_STRING_TOO_LONG (0x1fffffe8), crashing status and doctor commands. HeadroomFs now includes lstat, and logExists uses lstat to verify existence without allocating memory. --- src/codex/commands.ts | 2 +- src/frontdoor/server.ts | 33 +++++++++++++++++++++++---------- src/frontdoor/status.ts | 2 +- src/headroom/commands.ts | 2 +- src/headroom/state.ts | 2 +- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/codex/commands.ts b/src/codex/commands.ts index d9f0ede6..570b710f 100644 --- a/src/codex/commands.ts +++ b/src/codex/commands.ts @@ -168,7 +168,7 @@ export function collectCodexStatus(fsPort: HeadroomFs, paths: LayoutPaths, isRun usageSnapshotPath: codexUsageSnapshotPath(), usageSnapshotExists: fsPort.readFileUtf8(codexUsageSnapshotPath()) !== undefined, logPath: paths.frontdoorLogPath, - logExists: fsPort.readFileUtf8(paths.frontdoorLogPath) !== undefined, + logExists: fsPort.lstat(paths.frontdoorLogPath) !== undefined, }; } diff --git a/src/frontdoor/server.ts b/src/frontdoor/server.ts index de02c1d8..4a1147ed 100644 --- a/src/frontdoor/server.ts +++ b/src/frontdoor/server.ts @@ -15,6 +15,10 @@ const HEALTH_TIMEOUT_MS = 2_000; const HEALTH_START_BUDGET_MS = 5_000; const HEALTH_RETRY_MS = 50; +/** How many attempts listenFrontDoor makes to bind preferredPort before falling back to an ephemeral port. */ +const PREFERRED_PORT_MAX_ATTEMPTS = 6; +const PREFERRED_PORT_RETRY_MS = 50; + /** A front-door listener: plain HTTP for the direct listener only headroom reaches, HTTPS for the provider listener every provider session's child connects to. */ export type FrontDoorServer = http.Server | https.Server; @@ -166,17 +170,26 @@ export async function listenFrontDoor(server: FrontDoorServer, options: Readonly resolve(bound); }); }); - let bound: number; - if (preferredPort !== undefined) { - try { - bound = await tryListen(preferredPort); - } catch { - // The sticky port was taken between generations; any free port will do, and the supervisor logs the move. - bound = await tryListen(0); + const bindListener = async (): Promise => { + if (preferredPort === undefined) { + return await tryListen(0); } - } else { - bound = await tryListen(0); - } + for (let attempt = 1; attempt <= PREFERRED_PORT_MAX_ATTEMPTS; attempt += 1) { + try { + return await tryListen(preferredPort); + } catch { + if (attempt === PREFERRED_PORT_MAX_ATTEMPTS) { + // The sticky port was taken between generations; any free port will do, and the supervisor logs the move. + return await tryListen(0); + } + await new Promise((resolve) => { + setTimeout(resolve, PREFERRED_PORT_RETRY_MS); + }); + } + } + return await tryListen(0); + }; + const bound = await bindListener(); options.onBound?.(bound); if (options.onError !== undefined) { server.on("error", options.onError); diff --git a/src/frontdoor/status.ts b/src/frontdoor/status.ts index c0ddb327..ad3632a7 100644 --- a/src/frontdoor/status.ts +++ b/src/frontdoor/status.ts @@ -42,7 +42,7 @@ export function collectFrontDoorStatus(fsPort: HeadroomFs, socketTrust: Headroom sessions: listFrontDoorSessions(fsPort, paths.frontdoorSessionsDir).map((session) => ({ ...session, alive: isRunning(session.pid) })), headroomSocket: headroomSocketTarget(fsPort, socketTrust, paths), logPath: paths.frontdoorLogPath, - logExists: fsPort.readFileUtf8(paths.frontdoorLogPath) !== undefined, + logExists: fsPort.lstat(paths.frontdoorLogPath) !== undefined, }; } diff --git a/src/headroom/commands.ts b/src/headroom/commands.ts index 25c96308..61102f3f 100644 --- a/src/headroom/commands.ts +++ b/src/headroom/commands.ts @@ -101,7 +101,7 @@ export function collectHeadroomStatus(fsPort: HeadroomFs, paths: LayoutPaths, pr settings, settingsDrifted: state.settingsHash !== undefined && state.settingsHash !== hashSettings(settings), logPath: paths.headroomLogPath, - logExists: fsPort.readFileUtf8(paths.headroomLogPath) !== undefined, + logExists: fsPort.lstat(paths.headroomLogPath) !== undefined, }; } diff --git a/src/headroom/state.ts b/src/headroom/state.ts index 58317577..3e8c7017 100644 --- a/src/headroom/state.ts +++ b/src/headroom/state.ts @@ -45,7 +45,7 @@ export type HeadroomState = z.infer; /** The filesystem operations headroom coordination needs: the same shape as `FarmFs`, injected so every decision over state, sessions, and the start lock runs against an in-memory fake in tests. */ export type HeadroomFs = Pick< FarmFs, - "mkdirp" | "mkdirPrivate" | "readFileUtf8" | "writeFileUtf8" | "writeFilePrivate" | "writeFileExclusive" | "readdir" | "removeRecursive" + "lstat" | "mkdirp" | "mkdirPrivate" | "readFileUtf8" | "writeFileUtf8" | "writeFilePrivate" | "writeFileExclusive" | "readdir" | "removeRecursive" >; /**