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:
parent
ac9ad01f5d
commit
5443ae7daf
2 changed files with 41 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue