From 5443ae7daf7755100e267678eef78e23adc5f0af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 6 Sep 2026 22:15:34 +0200 Subject: [PATCH] Move the private command port with the candidate The first release that got far enough to report its own failure said EADDRINUSE 127.0.0.1:4101: the CultNet private command plane. I gave the HTTP listener a candidate bind and never noticed Heimdall has a second listener on a fixed port, so the candidate collided with the incumbent and died before it could warm. A fixed private port makes coexistence impossible, which is the premise the whole warm-then-cutover rests on. The private plane belongs to the same generation as the HTTP listener, so under Idunn its port is derived from the assigned candidate port with a fixed offset. Each generation gets its own pair, and the published command-boundary endpoint stays truthful because it reads the same config value. Outside Idunn the configured port is unchanged. The offset is large enough that a derived port cannot land inside the route's candidate range and collide with another generation's HTTP listener. 57 tests, 56 pass and the machine-id-bound one skipped on Windows. Co-Authored-By: Claude Opus 5 --- src/config.ts | 18 +++++++++++++++++- tests/idunn-candidate-bind.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index ec48061..714b903 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,13 @@ function trimTrailingSlash(value: string): string { return value.replace(/\/+$/, ""); } +/** + * Distance from a candidate's assigned HTTP port to its private command port. + * Large enough that the derived ports cannot land inside the route's candidate + * range and collide with another generation's HTTP listener. + */ +const PRIVATE_COMMAND_PORT_OFFSET = 1000; + function readInt(envValue: string | undefined, fallback: number): number { if (!envValue) { return fallback; @@ -229,7 +236,16 @@ export function loadConfig( host, port, privateCommandHost: env.GC_ACCESS_PRIVATE_COMMAND_HOST ?? "127.0.0.1", - privateCommandPort: readInt(env.GC_ACCESS_PRIVATE_COMMAND_PORT, 4101), + // The private command plane is a second listener belonging to the same + // generation as the HTTP one, so under Idunn its port has to move with the + // candidate too. A fixed port makes two generations impossible: the + // candidate collides with the incumbent on bind and the deployment dies + // before it can warm. Offsetting from the assigned candidate port keeps + // each generation's pair together and keeps the published command-boundary + // endpoint truthful, since it reads this same value. + privateCommandPort: candidateBind + ? candidateBind.port + PRIVATE_COMMAND_PORT_OFFSET + : readInt(env.GC_ACCESS_PRIVATE_COMMAND_PORT, 4101), workspaceRoot, dataRoot, idunnWriteLeasePath: env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE, diff --git a/tests/idunn-candidate-bind.test.ts b/tests/idunn-candidate-bind.test.ts index 7e1c560..fde901a 100644 --- a/tests/idunn-candidate-bind.test.ts +++ b/tests/idunn-candidate-bind.test.ts @@ -96,3 +96,27 @@ describe("Idunn state root argument", () => { expect(() => loadConfig({}, ["--state-root", "relative/path"])).toThrow(/must be absolute/); }); }); + +describe("private command plane port", () => { + it("moves with the candidate so two generations can coexist", () => { + // The incumbent holds the configured port. A candidate that reuses it dies + // on bind before it can warm, which is how the first sealed release failed. + const config = loadConfig( + { + GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:14103", + GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org", + GC_ACCESS_PRIVATE_COMMAND_PORT: "4101", + }, + [] + ); + + expect(config.port).toBe(14103); + expect(config.privateCommandPort).toBe(15103); + }); + + it("keeps the configured port when not launched by Idunn", () => { + expect( + loadConfig({ GC_ACCESS_PRIVATE_COMMAND_PORT: "4101" }, []).privateCommandPort + ).toBe(4101); + }); +});