Skip to content

Commit 2f2220a

Browse files
authored
Read an empty PORT as unset, so SERVER_PORT still moves the server and NaN never reaches Bun.serve (#340)
#312 read the port as `process.env.PORT ?? process.env.SERVER_PORT ?? "3001"`. `??` fires only on undefined, and a variable a compose file declares but the host never set, or `PORT=` left in a `.env`, arrives as an empty string: SERVER_PORT was never consulted, the disagreement check (`PORT && SERVER_PORT && …`) never fired, and `Number.parseInt("")` handed `Bun.serve` a NaN, which it answers by binding an ephemeral port. The server came up somewhere nobody had asked for, and the script polling SERVER_PORT reported one that never started — the failure #312 set out to remove, back through the other name. `parseInt` also parses a prefix, so `PORT=30o1` started the server on port 30 without a word. The port is now read by `loadConfig` through the same `optional` every other setting uses, where empty means unset, refusing anything that is not a whole number in 1–65535 the way the handoff caps and AGENT_STALL_TIMEOUT_MS already refuse a typo. Tests cover the empty-string case that is the whole point, the disagreement, and the refusals under both names. Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
1 parent cf696af commit 2f2220a

5 files changed

Lines changed: 110 additions & 13 deletions

File tree

‎.env.example‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ KEY_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
99
# AUDIT_RETENTION_DAYS=365
1010
# Two names for one number, and they must agree when both are set.
1111
#
12-
# The server accepts either PORT or SERVER_PORT (server/src/index.ts), preferring PORT when both
13-
# are present and refusing to start if they disagree, so a single edit is enough. scripts/start.sh
12+
# The server accepts either PORT or SERVER_PORT (server/src/config.ts), preferring PORT when both
13+
# are present and refusing to start if they disagree, so a single edit is enough. An empty value
14+
# is an unset one, so `PORT=` with SERVER_PORT set moves the server too. scripts/start.sh
1415
# and the app's Vite proxy read SERVER_PORT/APP_PORT, and docs/configuration.md documents
1516
# SERVER_PORT as the setting. Only PORT shipped here historically, so moving the server by
1617
# editing one line left the script still looking at 3001: it found whatever else was there,

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### An empty `PORT` no longer starts the server on a port nobody asked for
12+
13+
`PORT` and `SERVER_PORT` name one number, and either is meant to move the server. A `PORT` that was
14+
declared but empty — a compose file passing a variable the host never set, or `PORT=` left in a
15+
`.env` next to a `SERVER_PORT` that was set — was read as "set to nothing": `SERVER_PORT` was
16+
ignored, the number parsed to `NaN`, and the server came up on an ephemeral port while everything
17+
that polls `SERVER_PORT` reported it had never started. An empty value now counts as unset, the way
18+
every other setting already treats it, and a value that is not a whole port number (`30o1` used to
19+
start the server on port 30) refuses to start instead.
20+
1121
### Coworkers are made in a wizard and managed in a dialog
1222

1323
Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs,

‎server/src/config.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export type HandoffCaps = {
132132
};
133133

134134
export type DeploymentConfig = {
135+
/** The port the API listens on. Named `PORT` or `SERVER_PORT`; see `serverPort`. */
136+
port: number;
135137
databaseUrl: string;
136138
keyEncryptionKey: string;
137139
/**
@@ -854,6 +856,44 @@ function agentStallTimeoutMs(environment: Environment): number {
854856
return milliseconds;
855857
}
856858

859+
/** Where the API listens when nothing says otherwise: what `.env.example` and the image ship. */
860+
const DEFAULT_PORT = 3001;
861+
862+
/**
863+
* The port the API listens on, from either of its two names.
864+
*
865+
* `PORT` and `SERVER_PORT` name one number: either moves the server, and two that disagree are
866+
* refused at boot rather than half-applied. Read through `optional` like every other setting here,
867+
* and that is the point. An unset variable declared in a compose file, or left as `PORT=` in a
868+
* `.env`, arrives as an empty string rather than as absent, so `process.env.PORT ??
869+
* process.env.SERVER_PORT` never fell through to the second name, and `Number.parseInt("")` is
870+
* `NaN`. Given `NaN`, `Bun.serve` binds an ephemeral port: the server came up somewhere nobody had
871+
* asked for, `SERVER_PORT` ignored, and the script polling it reported a server that never
872+
* started — the failure #312 set out to remove, back through the other name.
873+
*
874+
* A value that is not a whole port number is refused for the reason the caps above are: `30o1`
875+
* used to start the server on port 30, and a typo has to fail where somebody is looking.
876+
*/
877+
function serverPort(environment: Environment): number {
878+
const read = (name: string): number | undefined => {
879+
const raw = optional(environment, name);
880+
if (raw === undefined) return undefined;
881+
const value = Number(raw);
882+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
883+
throw new Error(`${name} must be a whole number between 1 and 65535`);
884+
}
885+
return value;
886+
};
887+
const port = read("PORT");
888+
const serverPort = read("SERVER_PORT");
889+
if (port !== undefined && serverPort !== undefined && port !== serverPort) {
890+
throw new Error(
891+
`PORT (${port}) and SERVER_PORT (${serverPort}) disagree: set one or set both to the same value`,
892+
);
893+
}
894+
return port ?? serverPort ?? DEFAULT_PORT;
895+
}
896+
857897
export function loadConfig(
858898
environment: Environment = process.env,
859899
): DeploymentConfig {
@@ -863,6 +903,7 @@ export function loadConfig(
863903
const workerSharedSecret = optional(environment, "WORKER_SHARED_SECRET");
864904

865905
return {
906+
port: serverPort(environment),
866907
databaseUrl: required(environment, "DATABASE_URL"),
867908
keyEncryptionKey: keyEncryptionKey(environment),
868909
...(managedAgent ? { managedAgent } : {}),

‎server/src/index.ts‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,9 @@ const identifyActor: IdentifyActor = async (request) => {
143143
};
144144

145145
const config = loadConfig();
146-
const rawPort = process.env.PORT ?? process.env.SERVER_PORT ?? "3001";
147-
if (
148-
process.env.PORT &&
149-
process.env.SERVER_PORT &&
150-
process.env.PORT !== process.env.SERVER_PORT
151-
) {
152-
throw new Error(
153-
`PORT (${process.env.PORT}) and SERVER_PORT (${process.env.SERVER_PORT}) disagree: set one or set both to the same value`,
154-
);
155-
}
156-
const port = Number.parseInt(rawPort, 10);
146+
// Read with the rest of the configuration, where an empty variable is an absent one. See
147+
// `serverPort` in config.ts for what `process.env.PORT ?? …` did with `PORT=` instead.
148+
const port = config.port;
157149
const database = createDatabase(config.databaseUrl);
158150
await initializeDevActorUser(database, config.singleUser);
159151
// The vault, built before the agent store because a customer's agent may sit behind a key and that

‎server/tests/config.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,59 @@ describe("deployment configuration", () => {
394394
},
395395
);
396396

397+
test("listens on 3001 when neither PORT nor SERVER_PORT is set", () => {
398+
expect(loadConfig(baseEnvironment).port).toBe(3001);
399+
});
400+
401+
test("moves the server by either name", () => {
402+
expect(loadConfig({ ...baseEnvironment, PORT: "3005" }).port).toBe(3005);
403+
expect(loadConfig({ ...baseEnvironment, SERVER_PORT: "3005" }).port).toBe(
404+
3005,
405+
);
406+
expect(
407+
loadConfig({ ...baseEnvironment, PORT: " 3005 ", SERVER_PORT: "3005" })
408+
.port,
409+
).toBe(3005);
410+
});
411+
412+
/*
413+
* An unset variable declared in a compose file, or left as `PORT=` in a `.env`, arrives as an
414+
* empty string rather than as absent. `process.env.PORT ?? process.env.SERVER_PORT` saw the empty
415+
* string and never reached the second name, and `Number.parseInt("")` handed `Bun.serve` a NaN,
416+
* which it answers by binding an ephemeral port nobody asked for.
417+
*/
418+
test("reads SERVER_PORT when PORT is declared but empty, and the other way round", () => {
419+
expect(
420+
loadConfig({ ...baseEnvironment, PORT: "", SERVER_PORT: "3005" }).port,
421+
).toBe(3005);
422+
expect(
423+
loadConfig({ ...baseEnvironment, PORT: "3005", SERVER_PORT: "" }).port,
424+
).toBe(3005);
425+
expect(
426+
loadConfig({ ...baseEnvironment, PORT: "", SERVER_PORT: "" }).port,
427+
).toBe(3001);
428+
});
429+
430+
test("refuses to start when PORT and SERVER_PORT disagree", () => {
431+
expect(() =>
432+
loadConfig({ ...baseEnvironment, PORT: "3001", SERVER_PORT: "3005" }),
433+
).toThrow("PORT (3001) and SERVER_PORT (3005) disagree");
434+
});
435+
436+
// `Number.parseInt("30o1")` is 30, and the server used to come up there. Refused instead, the way
437+
// a mistyped cap is: a port has to fail at start-up, where somebody is looking.
438+
test.each(["30o1", "three", "0", "65536", "1.5", "-1"])(
439+
"refuses to start on PORT=%p",
440+
(value) => {
441+
expect(() => loadConfig({ ...baseEnvironment, PORT: value })).toThrow(
442+
"PORT must be a whole number between 1 and 65535",
443+
);
444+
expect(() =>
445+
loadConfig({ ...baseEnvironment, SERVER_PORT: value }),
446+
).toThrow("SERVER_PORT must be a whole number between 1 and 65535");
447+
},
448+
);
449+
397450
test("configures Docker as the per-Bot computer provider", () => {
398451
const config = loadConfig({
399452
...baseEnvironment,

0 commit comments

Comments
 (0)