Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion supervisor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions supervisor/src/listen-port.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
28 changes: 28 additions & 0 deletions supervisor/tests/listen-port.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});