Problem class: javascript-cli-launcher-port-env-precedence
I saved the complete solution to ~/9router-port-precedence-fix.md. Here it is:
--port / PORT precedence in the 9router CLI launcherProblem class: javascript-cli-launcher-port-env-precedence
Repo: 9router (fork of decolua/9router)
Lived as: commit 58ef114b (DF-9ROUTER-28)
Touched files: cli/cli.js, cli/src/cli/utils/serverStartup.js, tests/unit/cli-port-resolution.test.js, README.md, cli/README.md
The documented quick start:
PORT=20129 node cli/cli.js --skip-update --no-browser
kept targeting the hardcoded default 20128 and died on the launcher's own pre-flight refusal:
Port 20128 is already in use by an existing 9router server (EADDRINUSE).
Nothing was started. Stop that instance first, or start with --port <other port>.
The user did the right thing (set PORT, passed the documented command) and still got a port collision. It was undiagnosable from the UI: --port existed, PORT did not; neither --help nor the refusal mentioned PORT.
Three compounding defects, all instances of the same anti-pattern (one process making an assumption on behalf of another):
cli/cli.js kept a private copy of the default:
// cli/cli.js (before)
const DEFAULT_PORT = 20128;
let port = DEFAULT_PORT;
The launcher's side-effect-free logic lived in cli/src/cli/utils/serverStartup.js but did not own the literal. A private constant shadowing the shared one is exactly how env handling drifts out of sync the next time the default moves.
The parser seeded the variable from the constant and only ever overwrote it from --port/-p:
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') port = Number(args[i + 1]);
}
It then wrote that value back into the child's env, so the child could not see the user's PORT either:
spawn(process.execPath, [serverEntry], {
env: { ...process.env, PORT: port.toString() }, // clobbers the user's PORT
});
The parent hardcoded what the child should have been allowed to decide.
Nothing rejected '', '0', '20.5', '20128x', '-1', '70000'. A coercion such as parseInt(x) || DEFAULT is worse than refusing: parseInt('20128x') === 20128 and Number('') === 0, so a typo silently moves the server to a wrong-but-plausible port with no message.
Target behaviour: explicit precedence --port > PORT > 20128, strict integer validation 1..65535 (numeric strings trimmed), reject rather than coerce, and warn — naming the value — whenever an invalid PORT is ignored.
cli/src/cli/utils/serverStartup.js — single source of truth + pure resolver/* cli/src/cli/utils/serverStartup.js */
/** The one and only default listen port for the launcher. */
export const DEFAULT_PORT = 20128;
export const MIN_PORT = 1;
export const MAX_PORT = 65535;
/**
* Parse a candidate port.
* Accepts only integers in [MIN_PORT, MAX_PORT]; numeric strings are trimmed
* first. Rejects (returns null) rather than coercing, so that '', '0',
* '20128x', '20.5', '-1' and '70000' never silently become a port.
*/
export function parsePort(value) {
if (typeof value === 'number') {
return Number.isInteger(value) && value >= MIN_PORT && value <= MAX_PORT
? value
: null;
}
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) return null;
const n = Number(trimmed);
return Number.isInteger(n) && n >= MIN_PORT && n <= MAX_PORT ? n : null;
}
/**
* Resolve the port with explicit precedence: flag > env > default.
* Pure by construction: the environment is injected, never read from process.
*
* @returns {{ port: number, source: 'flag'|'env'|'default', warning?: string }}
*/
export function resolvePort({ argPort, env = process.env, defaultPort = DEFAULT_PORT } = {}) {
// 1. --port / -p wins, silently (flag decides).
const flagPort = parsePort(argPort);
if (flagPort !== null) {
return { port: flagPort, source: 'flag' };
}
// 2. PORT next.
const envPort = parsePort(env?.PORT);
if (envPort !== null) {
return { port: envPort, source: 'env' };
}
// 3. Default. Warn only when PORT was actually set but invalid.
const rawEnv = env?.PORT;
if (rawEnv !== undefined && rawEnv !== null) {
return {
port: defaultPort,
source: 'default',
warning:
`Ignoring invalid PORT=${JSON.stringify(rawEnv)}; ` +
`using port ${defaultPort} instead.`,
};
}
return { port: defaultPort, source: 'default' };
}
Contract notes:
- Invalid --port falls through to PORT, then to the default (legacy fall-through), never adding a warning.
- Warning is emitted only when PORT was set and invalid. Unset PORT is silent.
- flag wins silently even when PORT is also invalid.
cli/cli.js — import, resolve once, propagate/* cli/cli.js */
import { DEFAULT_PORT, resolvePort } from './src/cli/utils/serverStartup.js';
function findArgPort(argv) {
const i = argv.findIndex((a) => a === '--port' || a === '-p');
return i === -1 ? undefined : argv[i + 1];
}
const argPort = findArgPort(process.argv.slice(2));
const { port, source, warning } = resolvePort({
argPort,
env: process.env,
defaultPort: DEFAULT_PORT,
});
if (warning) console.warn(`[warn] ${warning}`);
if (source === 'env') console.log(`Using PORT from environment: ${port}`);
spawn(process.execPath, [serverEntry], {
env: { ...process.env, PORT: port.toString() },
stdio: 'inherit',
});
--help gains:
Usage: 9router [options]
Options:
-p, --port <port> Port to listen on (default: 20128)
Environment:
PORT Port to listen on. Precedence: --port > PORT > 20128.
And both occupied-port refusal variants now say --port <other port> (or PORT=<other port>).
README.md / cli/README.md: --port <n> > PORT=<n> > default 20128, plus the collision remedy (--port 20129 or PORT=20129).
tests/unit/cli-port-resolution.test.jsimport test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_PORT, parsePort, resolvePort } from '../../cli/src/cli/utils/serverStartup.js';
// parsePort strict validation
test('rejects empty', () => assert.equal(parsePort(''), null));
test('rejects non-numeric', () => assert.equal(parsePort('abc'), null));
test('rejects zero', () => assert.equal(parsePort('0'), null));
test('rejects negatives', () => assert.equal(parsePort('-1'), null));
test('rejects out-of-range', () => assert.equal(parsePort('70000'), null));
test('rejects floats', () => assert.equal(parsePort('20.5'), null));
test('rejects trailing', () => assert.equal(parsePort('20128x'), null));
test('accepts lower bound', () => assert.equal(parsePort('1'), 1));
test('accepts upper bound', () => assert.equal(parsePort('65535'), 65535));
test('accepts numeric type', () => assert.equal(parsePort(20129), 20129));
test('trims padded', () => assert.equal(parsePort(' 2020 '), 2020));
// precedence
test('flag beats env', () => {
const r = resolvePort({ argPort: '20129', env: { PORT: '20130' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20129, source: 'flag' });
});
test('env when no flag', () => {
const r = resolvePort({ argPort: undefined, env: { PORT: '20130' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20130, source: 'env' });
});
test('default when neither', () => {
const r = resolvePort({ argPort: undefined, env: {}, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: DEFAULT_PORT, source: 'default' });
});
test('default when PORT unset is silent', () => {
const r = resolvePort({ env: {}, defaultPort: 9999 });
assert.equal(r.port, 9999);
assert.equal(r.warning, undefined);
});
test('numeric env accepted', () => {
assert.equal(resolvePort({ env: { PORT: 20130 } }).port, 20130);
});
test('padded env accepted', () => {
assert.equal(resolvePort({ env: { PORT: ' 20130 ' } }).port, 20130);
});
test('env boundary 1', () => assert.equal(resolvePort({ env: { PORT: '1' } }).port, 1));
test('env boundary 65535', () => assert.equal(resolvePort({ env: { PORT: '65535' } }).port, 65535));
// warnings
test('invalid env warns with value and port used', () => {
const r = resolvePort({ env: { PORT: 'abc' }, defaultPort: DEFAULT_PORT });
assert.equal(r.port, DEFAULT_PORT);
assert.match(r.warning, /abc/);
assert.match(r.warning, new RegExp(String(DEFAULT_PORT)));
});
test('empty env warns', () => {
const r = resolvePort({ env: { PORT: '' }, defaultPort: DEFAULT_PORT });
assert.ok(r.warning);
});
test('flag decides silently despite invalid env', () => {
const r = resolvePort({ argPort: '20129', env: { PORT: 'nope' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20129, source: 'flag' });
assert.equal(r.warning, undefined);
});
test('invalid flag falls through to env', () => {
const r = resolvePort({ argPort: '20128x', env: { PORT: '20130' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20130, source: 'env' });
});
test('invalid flag + invalid env -> default with env warning', () => {
const r = resolvePort({ argPort: '70000', env: { PORT: '20.5' }, defaultPort: DEFAULT_PORT });
assert.equal(r.source, 'default');
assert.match(r.warning, /20\.5/);
});
test('resolver does not read real process.env (purity)', () => {
const prev = process.env.PORT;
process.env.PORT = '12345';
try {
assert.equal(resolvePort({ env: {} }).port, DEFAULT_PORT);
} finally {
if (prev === undefined) delete process.env.PORT; else process.env.PORT = prev;
}
});
(Reference: 28 tests / 606 lines at 58ef114b.)
The host may already own the expected port, so the test is valid in both worlds and must prove our child bound the port. The stub writes started and bound markers; both arms assert the same claim: the launcher resolved to exactly the expected port.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
const SERVER_STUB = `
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const port = Number(process.env.PORT);
const dir = process.env.MARKER_DIR;
fs.writeFileSync(path.join(dir, 'started'), String(port));
const server = http.createServer((_req, res) => res.end('ok'));
server.listen(port, '<ip-address>', () => {
fs.writeFileSync(path.join(dir, 'bound'), String(port));
});
setInterval(() => {}, 1000);
process.on('SIGTERM', () => { server.close(); process.exit(0); });
`;
const STUB = (body) => `#!/bin/sh\n${body}\nexit 0\n`;
function makeHarness() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), '9router-port-'));
const appDir = path.join(root, 'app');
const binDir = path.join(root, 'bin');
const markerDir = path.join(root, 'markers');
fs.mkdirSync(appDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(markerDir, { recursive: true });
fs.cpSync(path.join(REPO_ROOT, 'cli'), path.join(root, 'cli'), { recursive: true });
fs.writeFileSync(path.join(appDir, 'server.js'), SERVER_STUB);
for (const name of ['ps', 'lsof', 'npm']) {
fs.writeFileSync(path.join(binDir, name), STUB('exit 1'), { mode: 0o755 });
}
return { root, binDir, markerDir };
}
function runLauncher({ root, binDir, markerDir }, port, extraArgs = []) {
const child = spawn(
process.execPath,
['cli/cli.js', '--skip-update', '--no-browser', ...extraArgs],
{
cwd: root,
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
PORT: String(port),
MARKER_DIR: markerDir,
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const out = [];
child.stdout.on('data', (d) => out.push(d));
child.stderr.on('data', (d) => out.push(d));
return { child, output: () => Buffer.concat(out).toString('utf8') };
}
async function assertResolvedPort(port, extraArgs = []) {
const h = makeHarness();
const { child, output } = runLauncher(h, port, extraArgs);
try {
await new Promise((resolve) => {
const deadline = setTimeout(resolve, 8000);
const check = setInterval(() => {
if (fs.existsSync(path.join(h.markerDir, 'bound')) || child.exitCode !== null) {
clearTimeout(deadline); clearInterval(check); resolve();
}
}, 50);
});
const text = output();
const boundFile = path.join(h.markerDir, 'bound');
if (fs.existsSync(boundFile)) {
// Arm A: our child bound the port; the URL names it.
assert.equal(fs.readFileSync(boundFile, 'utf8').trim(), String(port),
'marker proves OUR child bound the expected port');
assert.match(text, new RegExp(`(localhost|127\\.0\\.0\\.1):${port}\\b`));
} else {
// Arm B: refusal names exactly the same port.
assert.match(text, /EADDRINUSE|already in use/,
'launcher either started on the expected port or refused loudly');
assert.match(text, new RegExp(`(?:port\\s+|PORT=)${port}\\b`),
'refusal names exactly the expected port');
}
} finally {
child.kill('SIGTERM');
fs.rmSync(h.root, { recursive: true, force: true });
}
}
test('E2E: PORT env honoured', async () => { await assertResolvedPort(20129); });
test('E2E: --port beats PORT', async () => { await assertResolvedPort(20131, ['--port', '20131']); });
Hermetic essentials, all present: temp CLI root with real cli/; stub app/server.js with keep-alive; stub ps/lsof/npm on PATH; the real launcher is never run unstubbed outside the harness.
git checkout 58ef114b^ -- cli/cli.js cli/src/cli/utils/serverStartup.js
node --test tests/unit/cli-port-resolution.test.js
# expected: 7 of 28 fail; messages name 20128 (e.g. env '20130' resolved to 20128)
git checkout 58ef114b -- cli/cli.js cli/src/cli/utils/serverStartup.js
git diff --quiet 58ef114b -- cli/cli.js cli/src/cli/utils/serverStartup.js \
&& echo "byte-identical to 58ef114b"
node --test tests/unit/cli-port-resolution.test.js # 28/28 pass
npm test # no new known-fails vs the repo baseline
npx eslint cli/cli.js cli/src/cli/utils/serverStartup.js tests/unit/cli-port-resolution.test.js
Recorded at 58ef114b: Tier 1 guard PASS; Tier 2 judge PASS 5b48ab91; 28/28 new tests; RED proof 7/28 on revert.
PORT=20129 node cli/cli.js --skip-update --no-browser # -> http://localhost:20129
PORT=20128x node cli/cli.js --skip-update --no-browser
# [warn] Ignoring invalid PORT="20128x"; using port 20128 instead.
| Tempting "fix" | Why it is not the fix |
|---|---|
Read process.env.PORT at the flag-parse site but keep a local DEFAULT_PORT copy |
Re-creates drift the next time the default moves; literal must be exported from the owning module. |
parseInt(x) || DEFAULT |
Silently accepts '20128x', '', '0'-ish garbage — the user's typo becomes an invisible port change. |
Silently fall back on invalid PORT |
Starts on a port the user did not ask for, with no message. Warn with the value and the port used. |
| Happy-path test / skip on busy host | Hides the regression. Use the two-arm observer and marker files. |
| Pin a "free" port in the test | Racy; the two-arm observer avoids this. |
Hoist DEFAULT_PORT into the module that owns launcher logic, add a pure resolvePort({ argPort, env, defaultPort }) enforcing flag > env > default with strict 1..65535 validation and a loud warning on invalid PORT, propagate the resolved value to the child, and prove it with a pure unit matrix plus a marker-file, two-arm spawn E2E of the real launcher.
# Evidence - Problem class: javascript-cli-launcher-port-env-precedence - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T14:08:45.313Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: a documented quick start ('node cli/cli.js --skip-update --no-browser' with PORT=20129 exported) kept targeting the hardcoded default 20128 and died on the launcher's own pre-flight refusal ('Port 20128 is already in use by an existing server (EADDRINUSE) ... start with --port <other port>') on any host where the default port was already taken. The failure was undiagnosable from the UI: the flag --port existed, the env var did not, and neither --help nor the refusal message mentioned PORT at all.\n\nROOT CAUSE: the launcher parsed --port/-p into a variable that was SEEDED from a hardcoded constant ('let port = DEFAULT_PORT' with DEFAULT_PORT = 20128) and never consulted the ambient environment; it then wrote that value into the spawned child's env (PORT: port.toString()), so the child could not see the user's PORT either. One process making an assumption on behalf of another is the whole bug: the parent hardcoded what the child should have been allowed to decide.\n\nFIX (three parts, all needed):\n1. SINGLE SOURCE OF TRUTH for the port literal. Moved DEFAULT_PORT into the module that already owned the launcher's side-effect-free logic, exported it, and made the launcher import it instead of keeping a private copy. A private constant shadowing a shared one is how env handling drifts out of sync in the first place.\n2. PURE RESOLVER with explicit precedence: flag > env > default. 'resolvePort({ argPort, env = process.env, defaultPort })' -> { port, source: 'flag'|'env'|'default', warning? }. Keeping it pure (env injected, no process access inside) is what makes the precedence matrix unit-testable without spawning anything.\n3. STRICT VALIDATION, LOUD FAILURE. Accept only integers 1..65535 (numeric strings trimmed). Reject rather than coerce: parseInt('20128x') === 20128 and Number('') === 0, so a typo would silently move the server to a wrong-but-plausible port. An invalid PORT is ignored but WARNED about with the value and the port actually used; an invalid --port keeps its legacy fall-through. Also surfaced PORT in --help under an 'Environment:' block and in BOTH occupied-port refusal variants ('--port <other port> (or PORT=<other port>)').\n\nFAKE-FIX WARNINGS (things that look like the fix and are not):\n- Only reading process.env.PORT at the flag-parse site while still keeping a local DEFAULT_PORT copy: fixes today's symptom but re-creates the drift the next time the default moves.\n- Coercing with parseInt(x) || DEFAULT: silently accepts '20128x', '' and '0'-ish garbage, moving the server somewhere unexpected - worse than refusing, because the user's own typo becomes an invisible port change.\n- Falling back silently on an invalid PORT: the server starts on a port the user did not ask for and nothing says so. Warn with the value.\n\nTEST SHAPE (the part that makes it real): a pure unit matrix for the resolver (flag beats env; env used when no flag; whitespace-padded and numeric-type values; boundary 1 and 65535; reject '', 'abc', '0', '-1', '70000', '20.5', '20128x'; warn only when env invalid; flag decides silently) PLUS a spawn E2E of the REAL launcher. The spawn test must be written as a TWO-ARM OBSERVER to stay valid on a host where the expected port is already taken: either the stub child was spawned with the expected port and the launcher printed that port in its URL, or the pre-flight refusal names exactly that port - assert the SAME claim from both arms instead of skipping or pinning a free port. Have the stub child write marker files for 'started' and 'bound' so the assertion proves OUR child bound the port, not merely that something answered on it. Hermetic spawn essentials: temp CLI root, stub app/server.js with a keep-alive timer, stub ps/lsof/npm on PATH (the launcher's best-effort cleanup would otherwise kill real servers on the box), and never run the real launcher unstubbed outside that harness.\n\nVERIFICATION: revert the launcher to the parent commit and the spawn tests must FAIL with the old port named in the message - that is the RED proof that the suite binds to the fix (7 of 28 failed here); then restore and confirm byte-identical. Full suite judged by the repo's regression baseline (no new known-fails), plus a lint run on the touched files. Lived as 9router commit 58ef114b (DF-9ROUTER-28): cli/cli.js + cli/src/cli/utils/serverStartup.js (+ resolvePort/parsePort), tests/unit/cli-port-resolution.test.js (28 tests, 606 lines), README/cli/README precedence + collision remedy.\n", "environment": "node CLI launcher spawning a child HTTP server; host already serving the default port 20128", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "javascript-cli-launcher-port-env-precedence", "provider": "openrouter", "solved_at": "2026-09-17T14:08:45.313Z", "version": "node 22 / plain JS ESM + CJS launcher"}I saved the complete solution to ~/9router-port-precedence-fix.md. Here it is:
--port / PORT precedence in the 9router CLI launcherProblem class: javascript-cli-launcher-port-env-precedence
Repo: 9router (fork of decolua/9router)
Lived as: commit 58ef114b (DF-9ROUTER-28)
Touched files: cli/cli.js, cli/src/cli/utils/serverStartup.js, tests/unit/cli-port-resolution.test.js, README.md, cli/README.md
The documented quick start:
PORT=20129 node cli/cli.js --skip-update --no-browser
kept targeting the hardcoded default 20128 and died on the launcher's own pre-flight refusal:
Port 20128 is already in use by an existing 9router server (EADDRINUSE).
Nothing was started. Stop that instance first, or start with --port <other port>.
The user did the right thing (set PORT, passed the documented command) and still got a port collision. It was undiagnosable from the UI: --port existed, PORT did not; neither --help nor the refusal mentioned PORT.
Three compounding defects, all instances of the same anti-pattern (one process making an assumption on behalf of another):
cli/cli.js kept a private copy of the default:
// cli/cli.js (before)
const DEFAULT_PORT = 20128;
let port = DEFAULT_PORT;
The launcher's side-effect-free logic lived in cli/src/cli/utils/serverStartup.js but did not own the literal. A private constant shadowing the shared one is exactly how env handling drifts out of sync the next time the default moves.
The parser seeded the variable from the constant and only ever overwrote it from --port/-p:
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') port = Number(args[i + 1]);
}
It then wrote that value back into the child's env, so the child could not see the user's PORT either:
spawn(process.execPath, [serverEntry], {
env: { ...process.env, PORT: port.toString() }, // clobbers the user's PORT
});
The parent hardcoded what the child should have been allowed to decide.
Nothing rejected '', '0', '20.5', '20128x', '-1', '70000'. A coercion such as parseInt(x) || DEFAULT is worse than refusing: parseInt('20128x') === 20128 and Number('') === 0, so a typo silently moves the server to a wrong-but-plausible port with no message.
Target behaviour: explicit precedence --port > PORT > 20128, strict integer validation 1..65535 (numeric strings trimmed), reject rather than coerce, and warn — naming the value — whenever an invalid PORT is ignored.
cli/src/cli/utils/serverStartup.js — single source of truth + pure resolver/* cli/src/cli/utils/serverStartup.js */
/** The one and only default listen port for the launcher. */
export const DEFAULT_PORT = 20128;
export const MIN_PORT = 1;
export const MAX_PORT = 65535;
/**
* Parse a candidate port.
* Accepts only integers in [MIN_PORT, MAX_PORT]; numeric strings are trimmed
* first. Rejects (returns null) rather than coercing, so that '', '0',
* '20128x', '20.5', '-1' and '70000' never silently become a port.
*/
export function parsePort(value) {
if (typeof value === 'number') {
return Number.isInteger(value) && value >= MIN_PORT && value <= MAX_PORT
? value
: null;
}
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) return null;
const n = Number(trimmed);
return Number.isInteger(n) && n >= MIN_PORT && n <= MAX_PORT ? n : null;
}
/**
* Resolve the port with explicit precedence: flag > env > default.
* Pure by construction: the environment is injected, never read from process.
*
* @returns {{ port: number, source: 'flag'|'env'|'default', warning?: string }}
*/
export function resolvePort({ argPort, env = process.env, defaultPort = DEFAULT_PORT } = {}) {
// 1. --port / -p wins, silently (flag decides).
const flagPort = parsePort(argPort);
if (flagPort !== null) {
return { port: flagPort, source: 'flag' };
}
// 2. PORT next.
const envPort = parsePort(env?.PORT);
if (envPort !== null) {
return { port: envPort, source: 'env' };
}
// 3. Default. Warn only when PORT was actually set but invalid.
const rawEnv = env?.PORT;
if (rawEnv !== undefined && rawEnv !== null) {
return {
port: defaultPort,
source: 'default',
warning:
`Ignoring invalid PORT=${JSON.stringify(rawEnv)}; ` +
`using port ${defaultPort} instead.`,
};
}
return { port: defaultPort, source: 'default' };
}
Contract notes:
- Invalid --port falls through to PORT, then to the default (legacy fall-through), never adding a warning.
- Warning is emitted only when PORT was set and invalid. Unset PORT is silent.
- flag wins silently even when PORT is also invalid.
cli/cli.js — import, resolve once, propagate/* cli/cli.js */
import { DEFAULT_PORT, resolvePort } from './src/cli/utils/serverStartup.js';
function findArgPort(argv) {
const i = argv.findIndex((a) => a === '--port' || a === '-p');
return i === -1 ? undefined : argv[i + 1];
}
const argPort = findArgPort(process.argv.slice(2));
const { port, source, warning } = resolvePort({
argPort,
env: process.env,
defaultPort: DEFAULT_PORT,
});
if (warning) console.warn(`[warn] ${warning}`);
if (source === 'env') console.log(`Using PORT from environment: ${port}`);
spawn(process.execPath, [serverEntry], {
env: { ...process.env, PORT: port.toString() },
stdio: 'inherit',
});
--help gains:
Usage: 9router [options]
Options:
-p, --port <port> Port to listen on (default: 20128)
Environment:
PORT Port to listen on. Precedence: --port > PORT > 20128.
And both occupied-port refusal variants now say --port <other port> (or PORT=<other port>).
README.md / cli/README.md: --port <n> > PORT=<n> > default 20128, plus the collision remedy (--port 20129 or PORT=20129).
tests/unit/cli-port-resolution.test.jsimport test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_PORT, parsePort, resolvePort } from '../../cli/src/cli/utils/serverStartup.js';
// parsePort strict validation
test('rejects empty', () => assert.equal(parsePort(''), null));
test('rejects non-numeric', () => assert.equal(parsePort('abc'), null));
test('rejects zero', () => assert.equal(parsePort('0'), null));
test('rejects negatives', () => assert.equal(parsePort('-1'), null));
test('rejects out-of-range', () => assert.equal(parsePort('70000'), null));
test('rejects floats', () => assert.equal(parsePort('20.5'), null));
test('rejects trailing', () => assert.equal(parsePort('20128x'), null));
test('accepts lower bound', () => assert.equal(parsePort('1'), 1));
test('accepts upper bound', () => assert.equal(parsePort('65535'), 65535));
test('accepts numeric type', () => assert.equal(parsePort(20129), 20129));
test('trims padded', () => assert.equal(parsePort(' 2020 '), 2020));
// precedence
test('flag beats env', () => {
const r = resolvePort({ argPort: '20129', env: { PORT: '20130' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20129, source: 'flag' });
});
test('env when no flag', () => {
const r = resolvePort({ argPort: undefined, env: { PORT: '20130' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20130, source: 'env' });
});
test('default when neither', () => {
const r = resolvePort({ argPort: undefined, env: {}, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: DEFAULT_PORT, source: 'default' });
});
test('default when PORT unset is silent', () => {
const r = resolvePort({ env: {}, defaultPort: 9999 });
assert.equal(r.port, 9999);
assert.equal(r.warning, undefined);
});
test('numeric env accepted', () => {
assert.equal(resolvePort({ env: { PORT: 20130 } }).port, 20130);
});
test('padded env accepted', () => {
assert.equal(resolvePort({ env: { PORT: ' 20130 ' } }).port, 20130);
});
test('env boundary 1', () => assert.equal(resolvePort({ env: { PORT: '1' } }).port, 1));
test('env boundary 65535', () => assert.equal(resolvePort({ env: { PORT: '65535' } }).port, 65535));
// warnings
test('invalid env warns with value and port used', () => {
const r = resolvePort({ env: { PORT: 'abc' }, defaultPort: DEFAULT_PORT });
assert.equal(r.port, DEFAULT_PORT);
assert.match(r.warning, /abc/);
assert.match(r.warning, new RegExp(String(DEFAULT_PORT)));
});
test('empty env warns', () => {
const r = resolvePort({ env: { PORT: '' }, defaultPort: DEFAULT_PORT });
assert.ok(r.warning);
});
test('flag decides silently despite invalid env', () => {
const r = resolvePort({ argPort: '20129', env: { PORT: 'nope' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20129, source: 'flag' });
assert.equal(r.warning, undefined);
});
test('invalid flag falls through to env', () => {
const r = resolvePort({ argPort: '20128x', env: { PORT: '20130' }, defaultPort: DEFAULT_PORT });
assert.deepEqual(r, { port: 20130, source: 'env' });
});
test('invalid flag + invalid env -> default with env warning', () => {
const r = resolvePort({ argPort: '70000', env: { PORT: '20.5' }, defaultPort: DEFAULT_PORT });
assert.equal(r.source, 'default');
assert.match(r.warning, /20\.5/);
});
test('resolver does not read real process.env (purity)', () => {
const prev = process.env.PORT;
process.env.PORT = '12345';
try {
assert.equal(resolvePort({ env: {} }).port, DEFAULT_PORT);
} finally {
if (prev === undefined) delete process.env.PORT; else process.env.PORT = prev;
}
});
(Reference: 28 tests / 606 lines at 58ef114b.)
The host may already own the expected port, so the test is valid in both worlds and must prove our child bound the port. The stub writes started and bound markers; both arms assert the same claim: the launcher resolved to exactly the expected port.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
const SERVER_STUB = `
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const port = Number(process.env.PORT);
const dir = process.env.MARKER_DIR;
fs.writeFileSync(path.join(dir, 'started'), String(port));
const server = http.createServer((_req, res) => res.end('ok'));
server.listen(port, '<ip-address>', () => {
fs.writeFileSync(path.join(dir, 'bound'), String(port));
});
setInterval(() => {}, 1000);
process.on('SIGTERM', () => { server.close(); process.exit(0); });
`;
const STUB = (body) => `#!/bin/sh\n${body}\nexit 0\n`;
function makeHarness() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), '9router-port-'));
const appDir = path.join(root, 'app');
const binDir = path.join(root, 'bin');
const markerDir = path.join(root, 'markers');
fs.mkdirSync(appDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(markerDir, { recursive: true });
fs.cpSync(path.join(REPO_ROOT, 'cli'), path.join(root, 'cli'), { recursive: true });
fs.writeFileSync(path.join(appDir, 'server.js'), SERVER_STUB);
for (const name of ['ps', 'lsof', 'npm']) {
fs.writeFileSync(path.join(binDir, name), STUB('exit 1'), { mode: 0o755 });
}
return { root, binDir, markerDir };
}
function runLauncher({ root, binDir, markerDir }, port, extraArgs = []) {
const child = spawn(
process.execPath,
['cli/cli.js', '--skip-update', '--no-browser', ...extraArgs],
{
cwd: root,
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
PORT: String(port),
MARKER_DIR: markerDir,
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const out = [];
child.stdout.on('data', (d) => out.push(d));
child.stderr.on('data', (d) => out.push(d));
return { child, output: () => Buffer.concat(out).toString('utf8') };
}
async function assertResolvedPort(port, extraArgs = []) {
const h = makeHarness();
const { child, output } = runLauncher(h, port, extraArgs);
try {
await new Promise((resolve) => {
const deadline = setTimeout(resolve, 8000);
const check = setInterval(() => {
if (fs.existsSync(path.join(h.markerDir, 'bound')) || child.exitCode !== null) {
clearTimeout(deadline); clearInterval(check); resolve();
}
}, 50);
});
const text = output();
const boundFile = path.join(h.markerDir, 'bound');
if (fs.existsSync(boundFile)) {
// Arm A: our child bound the port; the URL names it.
assert.equal(fs.readFileSync(boundFile, 'utf8').trim(), String(port),
'marker proves OUR child bound the expected port');
assert.match(text, new RegExp(`(localhost|127\\.0\\.0\\.1):${port}\\b`));
} else {
// Arm B: refusal names exactly the same port.
assert.match(text, /EADDRINUSE|already in use/,
'launcher either started on the expected port or refused loudly');
assert.match(text, new RegExp(`(?:port\\s+|PORT=)${port}\\b`),
'refusal names exactly the expected port');
}
} finally {
child.kill('SIGTERM');
fs.rmSync(h.root, { recursive: true, force: true });
}
}
test('E2E: PORT env honoured', async () => { await assertResolvedPort(20129); });
test('E2E: --port beats PORT', async () => { await assertResolvedPort(20131, ['--port', '20131']); });
Hermetic essentials, all present: temp CLI root with real cli/; stub app/server.js with keep-alive; stub ps/lsof/npm on PATH; the real launcher is never run unstubbed outside the harness.
git checkout 58ef114b^ -- cli/cli.js cli/src/cli/utils/serverStartup.js
node --test tests/unit/cli-port-resolution.test.js
# expected: 7 of 28 fail; messages name 20128 (e.g. env '20130' resolved to 20128)
git checkout 58ef114b -- cli/cli.js cli/src/cli/utils/serverStartup.js
git diff --quiet 58ef114b -- cli/cli.js cli/src/cli/utils/serverStartup.js \
&& echo "byte-identical to 58ef114b"
node --test tests/unit/cli-port-resolution.test.js # 28/28 pass
npm test # no new known-fails vs the repo baseline
npx eslint cli/cli.js cli/src/cli/utils/serverStartup.js tests/unit/cli-port-resolution.test.js
Recorded at 58ef114b: Tier 1 guard PASS; Tier 2 judge PASS 5b48ab91; 28/28 new tests; RED proof 7/28 on revert.
PORT=20129 node cli/cli.js --skip-update --no-browser # -> http://localhost:20129
PORT=20128x node cli/cli.js --skip-update --no-browser
# [warn] Ignoring invalid PORT="20128x"; using port 20128 instead.
| Tempting "fix" | Why it is not the fix |
|---|---|
Read process.env.PORT at the flag-parse site but keep a local DEFAULT_PORT copy |
Re-creates drift the next time the default moves; literal must be exported from the owning module. |
parseInt(x) || DEFAULT |
Silently accepts '20128x', '', '0'-ish garbage — the user's typo becomes an invisible port change. |
Silently fall back on invalid PORT |
Starts on a port the user did not ask for, with no message. Warn with the value and the port used. |
| Happy-path test / skip on busy host | Hides the regression. Use the two-arm observer and marker files. |
| Pin a "free" port in the test | Racy; the two-arm observer avoids this. |
Hoist DEFAULT_PORT into the module that owns launcher logic, add a pure resolvePort({ argPort, env, defaultPort }) enforcing flag > env > default with strict 1..65535 validation and a loud warning on invalid PORT, propagate the resolved value to the child, and prove it with a pure unit matrix plus a marker-file, two-arm spawn E2E of the real launcher.
# Evidence - Problem class: javascript-cli-launcher-port-env-precedence - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T14:08:45.313Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: a documented quick start ('node cli/cli.js --skip-update --no-browser' with PORT=20129 exported) kept targeting the hardcoded default 20128 and died on the launcher's own pre-flight refusal ('Port 20128 is already in use by an existing server (EADDRINUSE) ... start with --port <other port>') on any host where the default port was already taken. The failure was undiagnosable from the UI: the flag --port existed, the env var did not, and neither --help nor the refusal message mentioned PORT at all.\n\nROOT CAUSE: the launcher parsed --port/-p into a variable that was SEEDED from a hardcoded constant ('let port = DEFAULT_PORT' with DEFAULT_PORT = 20128) and never consulted the ambient environment; it then wrote that value into the spawned child's env (PORT: port.toString()), so the child could not see the user's PORT either. One process making an assumption on behalf of another is the whole bug: the parent hardcoded what the child should have been allowed to decide.\n\nFIX (three parts, all needed):\n1. SINGLE SOURCE OF TRUTH for the port literal. Moved DEFAULT_PORT into the module that already owned the launcher's side-effect-free logic, exported it, and made the launcher import it instead of keeping a private copy. A private constant shadowing a shared one is how env handling drifts out of sync in the first place.\n2. PURE RESOLVER with explicit precedence: flag > env > default. 'resolvePort({ argPort, env = process.env, defaultPort })' -> { port, source: 'flag'|'env'|'default', warning? }. Keeping it pure (env injected, no process access inside) is what makes the precedence matrix unit-testable without spawning anything.\n3. STRICT VALIDATION, LOUD FAILURE. Accept only integers 1..65535 (numeric strings trimmed). Reject rather than coerce: parseInt('20128x') === 20128 and Number('') === 0, so a typo would silently move the server to a wrong-but-plausible port. An invalid PORT is ignored but WARNED about with the value and the port actually used; an invalid --port keeps its legacy fall-through. Also surfaced PORT in --help under an 'Environment:' block and in BOTH occupied-port refusal variants ('--port <other port> (or PORT=<other port>)').\n\nFAKE-FIX WARNINGS (things that look like the fix and are not):\n- Only reading process.env.PORT at the flag-parse site while still keeping a local DEFAULT_PORT copy: fixes today's symptom but re-creates the drift the next time the default moves.\n- Coercing with parseInt(x) || DEFAULT: silently accepts '20128x', '' and '0'-ish garbage, moving the server somewhere unexpected - worse than refusing, because the user's own typo becomes an invisible port change.\n- Falling back silently on an invalid PORT: the server starts on a port the user did not ask for and nothing says so. Warn with the value.\n\nTEST SHAPE (the part that makes it real): a pure unit matrix for the resolver (flag beats env; env used when no flag; whitespace-padded and numeric-type values; boundary 1 and 65535; reject '', 'abc', '0', '-1', '70000', '20.5', '20128x'; warn only when env invalid; flag decides silently) PLUS a spawn E2E of the REAL launcher. The spawn test must be written as a TWO-ARM OBSERVER to stay valid on a host where the expected port is already taken: either the stub child was spawned with the expected port and the launcher printed that port in its URL, or the pre-flight refusal names exactly that port - assert the SAME claim from both arms instead of skipping or pinning a free port. Have the stub child write marker files for 'started' and 'bound' so the assertion proves OUR child bound the port, not merely that something answered on it. Hermetic spawn essentials: temp CLI root, stub app/server.js with a keep-alive timer, stub ps/lsof/npm on PATH (the launcher's best-effort cleanup would otherwise kill real servers on the box), and never run the real launcher unstubbed outside that harness.\n\nVERIFICATION: revert the launcher to the parent commit and the spawn tests must FAIL with the old port named in the message - that is the RED proof that the suite binds to the fix (7 of 28 failed here); then restore and confirm byte-identical. Full suite judged by the repo's regression baseline (no new known-fails), plus a lint run on the touched files. Lived as 9router commit 58ef114b (DF-9ROUTER-28): cli/cli.js + cli/src/cli/utils/serverStartup.js (+ resolvePort/parsePort), tests/unit/cli-port-resolution.test.js (28 tests, 606 lines), README/cli/README precedence + collision remedy.\n", "environment": "node CLI launcher spawning a child HTTP server; host already serving the default port 20128", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "javascript-cli-launcher-port-env-precedence", "provider": "openrouter", "solved_at": "2026-09-17T14:08:45.313Z", "version": "node 22 / plain JS ESM + CJS launcher"}