diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e0438..c9e8a7158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### An empty supervisor PORT is unset, not an ephemeral bind + +`PORT=` left blank in compose or a `.env` used to reach `Bun.serve` as `NaN`, so the supervisor +bound a random port while the published mapping still pointed at 4300. Empty now means the default +4300; a non-numeric or out-of-range value refuses to start instead of binding port 30 from a typo +like `30o0`. + ### Coworkers are made in a wizard and managed in a dialog Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, diff --git a/supervisor/src/index.ts b/supervisor/src/index.ts index 41fc2e08c..89357ccbc 100644 --- a/supervisor/src/index.ts +++ b/supervisor/src/index.ts @@ -12,6 +12,7 @@ import { } from "./docker"; import { registerEntry } from "./identity"; import { namesFor } from "./names"; +import { listenPort } from "./listen-port"; /** * The container supervisor: the only thing here that holds the Docker socket. @@ -43,7 +44,12 @@ import { namesFor } from "./names"; * root on the host, so missing authentication is a deployment failure. */ -const port = Number.parseInt(process.env.PORT ?? "4300", 10); +const resolvedPort = listenPort(process.env.PORT, 4300); +if (!resolvedPort.ok) { + console.error(resolvedPort.reason); + process.exit(1); +} +const port = resolvedPort.port; const token = process.env.SUPERVISOR_TOKEN?.trim(); if (!token) { console.error( diff --git a/supervisor/src/listen-port.ts b/supervisor/src/listen-port.ts new file mode 100644 index 000000000..2e0b2bedd --- /dev/null +++ b/supervisor/src/listen-port.ts @@ -0,0 +1,29 @@ +/** + * Listen port for the container supervisor. + * + * An empty `PORT=` (compose blank, leftover `.env` line) is unset, not zero — the same empty-string + * trap #96/#114/#312 found for the server and computer. `??` only fires on undefined, so + * `Number.parseInt("", 10)` used to be `NaN` and `Bun.serve({ port: NaN })` bound an ephemeral port + * while compose still published 4300. Prefix typos (`30o0`) also used to start on 30 via parseInt. + */ +export function listenPort( + raw: string | undefined, + fallback: number, +): { ok: true; port: number } | { ok: false; reason: string } { + const trimmed = raw?.trim(); + if (!trimmed) return { ok: true, port: fallback }; + if (!/^\d+$/.test(trimmed)) { + return { + ok: false, + reason: `PORT must be a whole number from 1 to 65535 (got ${JSON.stringify(raw)}).`, + }; + } + const value = Number.parseInt(trimmed, 10); + if (value < 1 || value > 65535) { + return { + ok: false, + reason: `PORT must be a whole number from 1 to 65535 (got ${JSON.stringify(raw)}).`, + }; + } + return { ok: true, port: value }; +} diff --git a/supervisor/tests/listen-port.test.ts b/supervisor/tests/listen-port.test.ts new file mode 100644 index 000000000..503f1c53c --- /dev/null +++ b/supervisor/tests/listen-port.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { listenPort } from "../src/listen-port"; + +/** + * Empty PORT must not become NaN / an ephemeral bind. Same empty-string trap as the API server. + */ +describe("supervisor listen port", () => { + test("unset and empty string fall back to 4300", () => { + expect(listenPort(undefined, 4300)).toEqual({ ok: true, port: 4300 }); + expect(listenPort("", 4300)).toEqual({ ok: true, port: 4300 }); + expect(listenPort(" ", 4300)).toEqual({ ok: true, port: 4300 }); + }); + + test("a whole number in range is accepted", () => { + expect(listenPort("4300", 4300)).toEqual({ ok: true, port: 4300 }); + expect(listenPort("4500", 4300)).toEqual({ ok: true, port: 4500 }); + expect(listenPort("1", 4300)).toEqual({ ok: true, port: 1 }); + expect(listenPort("65535", 4300)).toEqual({ ok: true, port: 65535 }); + }); + + test("prefix typos and out-of-range values are refused", () => { + expect(listenPort("30o0", 4300).ok).toBe(false); + expect(listenPort("0", 4300).ok).toBe(false); + expect(listenPort("65536", 4300).ok).toBe(false); + expect(listenPort("-1", 4300).ok).toBe(false); + expect(listenPort("1.5", 4300).ok).toBe(false); + }); +});