Heimdall/tests/idunn-candidate-bind.test.ts
Meta Vens 5443ae7daf 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>
2026-09-06 22:15:34 +02:00

122 lines
4.4 KiB
TypeScript

import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
describe("Idunn candidate bind", () => {
it("falls back to HOST/PORT when Idunn is not the launcher", () => {
const config = loadConfig({ HOST: "127.0.0.1", PORT: "4100" });
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(4100);
});
it("takes the candidate socket over HOST/PORT", () => {
// Idunn owns the listen address for a candidate incarnation, because the
// incumbent still holds the ordinary one. HOST/PORT are not consulted.
const config = loadConfig({
HOST: "127.0.0.1",
PORT: "4100",
GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833",
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
});
expect(config.port).toBe(18833);
expect(config.host).toBe("127.0.0.1");
});
it("never advertises the candidate socket as the public base URL", () => {
// The regression this guards: publicBaseUrl defaults to host:port, and it
// builds the OAuth callback registered with each provider. Deriving it
// from an ephemeral candidate port would hand providers a URL that stops
// resolving the moment the candidate is promoted or discarded.
const config = loadConfig({
GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833",
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
});
expect(config.publicBaseUrl).toBe("https://heimdall.gamecult.org");
expect(config.publicBaseUrl).not.toContain("18833");
});
it("refuses to start under Idunn without an explicit public base URL", () => {
expect(() =>
loadConfig({ GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833" })
).toThrow(/GC_ACCESS_BASE_URL is required/);
});
it("rejects a non-loopback or unusable candidate socket", () => {
for (const value of ["0.0.0.0:18833", "10.77.0.1:18833", "127.0.0.1:0", "127.0.0.1", "127.0.0.1:not-a-port"]) {
expect(() =>
loadConfig({
GAMECULT_IDUNN_CANDIDATE_BIND: value,
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
})
).toThrow(/GAMECULT_IDUNN_CANDIDATE_BIND/);
}
});
it("accepts a bracketed IPv6 loopback", () => {
const config = loadConfig({
GAMECULT_IDUNN_CANDIDATE_BIND: "[::1]:18833",
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
});
expect(config.host).toBe("::1");
expect(config.port).toBe(18833);
});
});
describe("Idunn state root argument", () => {
it("takes --state-root over GC_ACCESS_DATA_ROOT", () => {
// Idunn owns where state lives: the recipe declares slots relative to
// state_root and the binding supplies the absolute path as a launch
// argument. The env var remains for runs outside Idunn.
const config = loadConfig({ GC_ACCESS_DATA_ROOT: "/tmp/ignored" }, [
"--state-root",
"/var/lib/gamecult/heimdall",
]);
expect(config.dataRoot).toBe("/var/lib/gamecult/heimdall");
expect(config.cultCachePath).toContain("heimdall.service.cc");
expect(config.cultCachePath).toBe(
path.join(config.dataRoot, "cultcache", "heimdall.service.cc")
);
});
it("falls back to GC_ACCESS_DATA_ROOT when the argument is absent", () => {
expect(loadConfig({ GC_ACCESS_DATA_ROOT: "/srv/heimdall/state" }, []).dataRoot).toBe(
"/srv/heimdall/state"
);
});
it("rejects a missing or relative --state-root", () => {
expect(() => loadConfig({}, ["--state-root"])).toThrow(/requires a path/);
expect(() => loadConfig({}, ["--state-root", "--other"])).toThrow(/requires a path/);
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);
});
});