Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/26270-dev-port-shift-reaches-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/cli': patch
---

fix(cli): when `os dev` shifts off a busy port, the config reads the port the server binds

Clause-②: no
Metadata-protocol: none

- **Before.** The development auto-shift ran in the `serve` child, after `os dev`'s compile and the child's own config load had both read the requested port. On `examples/app-showcase` with 41310 held, `os dev -p 41310` printed `Port 41310 is in use — serving on 41311 instead`, yet `GET /api/v1/meta/connector/showcase_status_api` served `http://127.0.0.1:41310` and `showcase_declarative_connector_ping` answered `400 FLOW_FAILED`. The same held for a port from `OS_PORT`, and for a direct `os serve --dev` (or `os serve` under `NODE_ENV=development`), whose dispatch dialled the busy port.
- **Now.** `os dev` runs the same walk before it compiles. On a shift it prints the same notice, exports the port it landed on as `OS_PORT`/`PORT` (the channel a port flag already uses) for the compile, the watch recompiles and the server, and hands the server that port. `os serve` exports its shifted port the same way before it loads the config and before a direct `os serve --dev` compiles. The same boots now serve and ping the port they bound, and the ping answers 200.
- Not covered: a port taken between `os dev`'s probe and the server's bind makes the server shift once more. It says so and its own config reads the new port, but `/api/v1/meta` keeps the probed one until the next recompile.
45 changes: 42 additions & 3 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import {
} from '../utils/dev-tls-contract.js';
// The auth base-URL precedence chain, borrowed from the command that owns it
// (#16734). ⛔ `dev` declares no chain of its own — see printMcpConnectHint.
import { resolveAuthBaseUrl } from './serve.js';
// The development auto-shift and its notice, borrowed from the command that owns
// them (#26270): this process runs the same walk before it compiles, so the
// config reads the port the `serve` child will bind.
import { resolveAuthBaseUrl, getAvailablePort, formatDevPortShiftNotice } from './serve.js';
import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime';

/**
Expand Down Expand Up @@ -475,7 +478,7 @@ export default class Dev extends Command {
printKV('Config', configPath, '📂');
}

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

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

const requestedPort = port ?? '3000';

// ── The port the server will bind is the port the config reads (#26270) ──
// The `serve` child shifts off a busy port, but it did so after the
// compile below and its own config load had both read the requested
// port. Measured on the showcase with 41310 held: `os dev -p 41310`
// served on 41311 while `/api/v1/meta/connector/showcase_status_api`
// named 41310 and the self-ping flow answered `400 FLOW_FAILED`.
//
// So this process runs the child's walk (`getAvailablePort`, from the
// same requested port) ahead of the first config evaluation. On a shift
// it says so in the child's words, exports the port on the one channel a
// config reads (`portFlagEnv`, for the compile, the watch recompiles and
// the child), and forwards it as the child's `--port`, so the child binds
// it without walking again and every restart keeps it.
//
// ⚠️ The probe releases the port before the child binds it. A port taken
// in that window makes the child shift once more: it says so, and exports
// its own port for the config it loads (`serve.ts`), but the artifact
// compiled here keeps this port until the next recompile. An exhausted
// walk is left to the child, which repeats it and prints why.
const askedFor = parseRequestedPort(requestedPort);
if (askedFor !== null) {
let bound = askedFor;
try {
bound = await getAvailablePort(askedFor);
} catch {
// The child's own walk reports this (`formatExhaustedPortSearchNotice`).
}
if (bound !== askedFor) {
// stderr, for the #7915 reason the port refusal above gives.
process.stderr.write(`${formatDevPortShiftNotice(askedFor, bound)}\n`);
Object.assign(process.env, portFlagEnv(bound));
port = String(bound);
}
}

// Compile only when the boot's artifact is one this command builds — a
// pinned artifact means "use this, don't rebuild" — and then by default
// (#26130, `devCompilesAtBoot`).
Expand Down Expand Up @@ -781,7 +821,6 @@ export default class Dev extends Command {
printKV('Database', redactConnectionUrl(effectiveDb), '🗄️');

const binPath = process.argv[1];
const requestedPort = port ?? '3000';

// ── Serve child under restart supervision (#5148) ───────────────
// The watcher below rebuilds dist/objectstack.json, but a running
Expand Down
38 changes: 30 additions & 8 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,26 @@ export function formatExhaustedPortSearchNotice(requestedPort: number, cause: un
);
}

/**
* The notice for a development auto-shift that succeeded (#12543): the port
* asked for, that it could not be had, and the port taken instead.
*
* One function because two processes print it (#26270): `os serve` when its
* own walk shifts, and the `os dev` parent, which runs the same walk before it
* compiles so the config reads the port the server will bind. Whichever shifts
* says so in these words; the other never shifts on that boot.
*/
export function formatDevPortShiftNotice(requestedPort: number, port: number): string {
return (
'\n'
+ chalk.yellow(` ⚠ Port ${requestedPort} is in use — serving on ${port} instead.\n`)
+ chalk.dim(` Development auto-shift: ${requestedPort} was not free, so this server took\n`)
+ chalk.dim(` the next one that was (${port}). Anything still pointed at ${requestedPort} — a\n`)
+ chalk.dim(' proxy, an OAuth callback URL, another terminal, a test harness — is\n')
+ chalk.dim(` talking to whatever holds ${requestedPort}, not to this server.`)
);
}

/**
* The port the HTTP server ACTUALLY bound, read off the transport that bound it
* (#13062).
Expand Down Expand Up @@ -2344,14 +2364,16 @@ export default class Serve extends Command {
// line cannot be swallowed, and it survives a boot that dies later. A
// drifted port is a plausible CAUSE of such a death, so the notice has
// to outlive it.
printDiagnostic(
'\n'
+ chalk.yellow(` ⚠ Port ${requestedPort} is in use — serving on ${port} instead.\n`)
+ chalk.dim(` Development auto-shift: ${requestedPort} was not free, so this server took\n`)
+ chalk.dim(` the next one that was (${port}). Anything still pointed at ${requestedPort} — a\n`)
+ chalk.dim(' proxy, an OAuth callback URL, another terminal, a test harness — is\n')
+ chalk.dim(` talking to whatever holds ${requestedPort}, not to this server.`),
);
printDiagnostic(formatDevPortShiftNotice(requestedPort, port));
// ── …and the shifted port is the port the config reads (#26270) ──
// The config is loaded further down in this process, and it may read
// `OS_PORT`/`PORT` at module evaluation (the showcase's self URL does),
// as may the dev compile a direct `os serve --dev` runs. Without this
// they read the port that was busy: measured on the showcase,
// `/api/v1/meta/connector/showcase_status_api` named the held port and
// the self-ping flow answered `400 FLOW_FAILED`. Exported on the same
// two names a port flag is (`portFlagEnv`), so nothing restates them.
Object.assign(process.env, portFlagEnv(port));
}
} else if (!(await isPortAvailable(requestedPort))) {
// One write, for the reason spelled out at the "Nothing to serve" exit
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/utils/port-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,10 @@ export function portTextReadNotice(
* watch recompiles and the `serve` child all run in (#26243).
* - `os serve` merges it into its own `process.env` before it loads the
* config (#26243).
* - A development auto-shift exports the port it shifted to the same way,
* in the `os dev` parent before its compile and in `os serve` before its
* config load, so the config reads the port bound rather than the busy
* one it asked for (#26270).
*
* Measured before #26243, on `examples/app-showcase` with no `OS_PORT`/`PORT`:
* `os dev -p 41302` and `os serve --port 41303` both served
Expand Down
Loading
Loading