Skip to content

Commit bc2d71e

Browse files
committed
fix(cli): os dev runs the dev auto-shift before it compiles and exports the shifted port, so the config reads the port the server binds
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NQ4sTujBVMFFGmdXcRmdd5
1 parent 1084d2e commit bc2d71e

3 files changed

Lines changed: 76 additions & 11 deletions

File tree

‎packages/cli/src/commands/dev.ts‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ import {
5353
} from '../utils/dev-tls-contract.js';
5454
// The auth base-URL precedence chain, borrowed from the command that owns it
5555
// (#16734). ⛔ `dev` declares no chain of its own — see printMcpConnectHint.
56-
import { resolveAuthBaseUrl } from './serve.js';
56+
// The development auto-shift and its notice, borrowed from the command that owns
57+
// them (#26270): this process runs the same walk before it compiles, so the
58+
// config reads the port the `serve` child will bind.
59+
import { resolveAuthBaseUrl, getAvailablePort, formatDevPortShiftNotice } from './serve.js';
5760
import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime';
5861

5962
/**
@@ -516,7 +519,7 @@ export default class Dev extends Command {
516519
printKV('Config', configPath, '📂');
517520
}
518521

519-
const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
522+
let port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
520523

521524
// ── dev's own door on the ONE port contract (#12673) ──────────────
522525
// Everything about the port — the range, the reader, the refusal prose —
@@ -572,6 +575,43 @@ export default class Dev extends Command {
572575
if (flags.port !== undefined) Object.assign(process.env, portFlagEnv(parseRequestedPort(port) ?? undefined));
573576
}
574577

578+
const requestedPort = port ?? '3000';
579+
580+
// ── The port the server will bind is the port the config reads (#26270) ──
581+
// The `serve` child shifts off a busy port, but it did so after the
582+
// compile below and its own config load had both read the requested
583+
// port. Measured on the showcase with 41310 held: `os dev -p 41310`
584+
// served on 41311 while `/api/v1/meta/connector/showcase_status_api`
585+
// named 41310 and the self-ping flow answered `400 FLOW_FAILED`.
586+
//
587+
// So this process runs the child's walk (`getAvailablePort`, from the
588+
// same requested port) ahead of the first config evaluation. On a shift
589+
// it says so in the child's words, exports the port on the one channel a
590+
// config reads (`portFlagEnv`, for the compile, the watch recompiles and
591+
// the child), and forwards it as the child's `--port`, so the child binds
592+
// it without walking again and every restart keeps it.
593+
//
594+
// ⚠️ The probe releases the port before the child binds it. A port taken
595+
// in that window makes the child shift once more: it says so, and exports
596+
// its own port for the config it loads (`serve.ts`), but the artifact
597+
// compiled here keeps this port until the next recompile. An exhausted
598+
// walk is left to the child, which repeats it and prints why.
599+
const askedFor = parseRequestedPort(requestedPort);
600+
if (askedFor !== null) {
601+
let bound = askedFor;
602+
try {
603+
bound = await getAvailablePort(askedFor);
604+
} catch {
605+
// The child's own walk reports this (`formatExhaustedPortSearchNotice`).
606+
}
607+
if (bound !== askedFor) {
608+
// stderr, for the #7915 reason the port refusal above gives.
609+
process.stderr.write(`${formatDevPortShiftNotice(askedFor, bound)}\n`);
610+
Object.assign(process.env, portFlagEnv(bound));
611+
port = String(bound);
612+
}
613+
}
614+
575615
// Compile only when the boot's artifact is one this command builds — a
576616
// pinned artifact means "use this, don't rebuild" — and then by default
577617
// (#26130, `devCompilesAtBoot`).
@@ -822,7 +862,6 @@ export default class Dev extends Command {
822862
printKV('Database', redactConnectionUrl(effectiveDb), '🗄️');
823863

824864
const binPath = process.argv[1];
825-
const requestedPort = port ?? '3000';
826865

827866
// ── Serve child under restart supervision (#5148) ───────────────
828867
// The watcher below rebuilds dist/objectstack.json, but a running

‎packages/cli/src/commands/serve.ts‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,26 @@ export function formatExhaustedPortSearchNotice(requestedPort: number, cause: un
467467
);
468468
}
469469

470+
/**
471+
* The notice for a development auto-shift that succeeded (#12543): the port
472+
* asked for, that it could not be had, and the port taken instead.
473+
*
474+
* One function because two processes print it (#26270): `os serve` when its
475+
* own walk shifts, and the `os dev` parent, which runs the same walk before it
476+
* compiles so the config reads the port the server will bind. Whichever shifts
477+
* says so in these words; the other never shifts on that boot.
478+
*/
479+
export function formatDevPortShiftNotice(requestedPort: number, port: number): string {
480+
return (
481+
'\n'
482+
+ chalk.yellow(` ⚠ Port ${requestedPort} is in use — serving on ${port} instead.\n`)
483+
+ chalk.dim(` Development auto-shift: ${requestedPort} was not free, so this server took\n`)
484+
+ chalk.dim(` the next one that was (${port}). Anything still pointed at ${requestedPort} — a\n`)
485+
+ chalk.dim(' proxy, an OAuth callback URL, another terminal, a test harness — is\n')
486+
+ chalk.dim(` talking to whatever holds ${requestedPort}, not to this server.`)
487+
);
488+
}
489+
470490
/**
471491
* The port the HTTP server ACTUALLY bound, read off the transport that bound it
472492
* (#13062).
@@ -2340,14 +2360,16 @@ export default class Serve extends Command {
23402360
// line cannot be swallowed, and it survives a boot that dies later. A
23412361
// drifted port is a plausible CAUSE of such a death, so the notice has
23422362
// to outlive it.
2343-
printDiagnostic(
2344-
'\n'
2345-
+ chalk.yellow(` ⚠ Port ${requestedPort} is in use — serving on ${port} instead.\n`)
2346-
+ chalk.dim(` Development auto-shift: ${requestedPort} was not free, so this server took\n`)
2347-
+ chalk.dim(` the next one that was (${port}). Anything still pointed at ${requestedPort} — a\n`)
2348-
+ chalk.dim(' proxy, an OAuth callback URL, another terminal, a test harness — is\n')
2349-
+ chalk.dim(` talking to whatever holds ${requestedPort}, not to this server.`),
2350-
);
2363+
printDiagnostic(formatDevPortShiftNotice(requestedPort, port));
2364+
// ── …and the shifted port is the port the config reads (#26270) ──
2365+
// The config is loaded further down in this process, and it may read
2366+
// `OS_PORT`/`PORT` at module evaluation (the showcase's self URL does),
2367+
// as may the dev compile a direct `os serve --dev` runs. Without this
2368+
// they read the port that was busy: measured on the showcase,
2369+
// `/api/v1/meta/connector/showcase_status_api` named the held port and
2370+
// the self-ping flow answered `400 FLOW_FAILED`. Exported on the same
2371+
// two names a port flag is (`portFlagEnv`), so nothing restates them.
2372+
Object.assign(process.env, portFlagEnv(port));
23512373
}
23522374
} else if (!(await isPortAvailable(requestedPort))) {
23532375
// One write, for the reason spelled out at the "Nothing to serve" exit

‎packages/cli/src/utils/port-contract.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,10 @@ export function portTextReadNotice(
425425
* watch recompiles and the `serve` child all run in (#26243).
426426
* - `os serve` merges it into its own `process.env` before it loads the
427427
* config (#26243).
428+
* - A development auto-shift exports the port it shifted to the same way,
429+
* in the `os dev` parent before its compile and in `os serve` before its
430+
* config load, so the config reads the port bound rather than the busy
431+
* one it asked for (#26270).
428432
*
429433
* Measured before #26243, on `examples/app-showcase` with no `OS_PORT`/`PORT`:
430434
* `os dev -p 41302` and `os serve --port 41303` both served

0 commit comments

Comments
 (0)