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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-06 22:15:34 +02:00
commit 5443ae7daf
2 changed files with 41 additions and 1 deletions

View file

@ -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,

View file

@ -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);
});
});