From 1084d2e62cb0ff888d6173a56977a23ccf2cbcc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:48:18 +0000 Subject: [PATCH 1/5] test(cli): os dev with the requested port held serves, dispatches and pings on the shifted port Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NQ4sTujBVMFFGmdXcRmdd5 --- ...v-connector-env-parity.integration.test.ts | 126 +++++++++++++++++- 1 file changed, 119 insertions(+), 7 deletions(-) diff --git a/packages/cli/test/dev-connector-env-parity.integration.test.ts b/packages/cli/test/dev-connector-env-parity.integration.test.ts index bd5cbbb62e0..f98d26e8697 100644 --- a/packages/cli/test/dev-connector-env-parity.integration.test.ts +++ b/packages/cli/test/dev-connector-env-parity.integration.test.ts @@ -52,12 +52,28 @@ * module is the only copy), has no `OS_PORT` at all, and the materialized * `baseUrl` must read N. * + * ## #26270 — a busy port's shift is the port the config reads + * + * `os dev` shifts off a busy port, but the shift happened in the `serve` + * child after the parent's compile and the child's config load had both read + * the requested port. Measured on the showcase with 41310 held: `os dev -p + * 41310` served on 41311, `/api/v1/meta/connector/showcase_status_api` named + * 41310 and the self-ping flow answered `400 FLOW_FAILED`. The `os dev` parent + * now runs the child's walk before it compiles and exports the port it lands + * on (`portFlagEnv`). + * + * So the third boot here holds a port for its whole life (`holdPort`), asks + * `os dev -p` for it, and reads the shifted port out of the child's own + * banner. The served and the materialized `baseUrl` must both name that port, + * and a flow pinging the app through the connector (`fxp_ping`, the fixture's + * `showcase_declarative_connector_ping`) must succeed there. + * * ## Spawn shape * * `bin/run.js` with `NODE_ENV` unset, the shipped entry resolving commands from * `dist/` (`requireBuiltCli`), in its own process group (`dev` supervises a * `serve` grandchild). Each boot asks for a NON-ZERO port drawn with - * `reservePort()`, because that number is the subject: the config must read + * `reservePort()` (the busy-port boot holds its own with `holdPort()`), because that number is the subject: the config must read * the port the flag names, before the boot, and a kernel-assigned `-p 0` read * back afterwards cannot be known to the config (the draw is ledgered in * `released-port-production-spawn.pin.test.ts`). A lost race on that draw is @@ -73,9 +89,11 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { + boundPortFromBanner, childEnv, E2E_AUTH_SECRET, E2E_SECRET_KEY, + holdPort, portContentionError, portDriftError, probeThroughChild, @@ -101,6 +119,8 @@ const selfUrlFor = (port: number | string) => `http://127.0.0.1:${port}`; const CONNECTOR = 'fxp_self'; /** Where the fixture provider's factory records the `providerConfig` it was handed. */ const MATERIALIZED_FILE = 'materialized-provider-config.json'; +/** The flow that pings the app through the connector, as the showcase's self-ping does. */ +const PING_FLOW = 'fxp_ping'; /** The development dev-admin seed — the operator on this boot. */ const EMAIL = 'admin@objectos.ai'; const PASSWORD = 'admin123'; @@ -130,7 +150,9 @@ const connector = (baseUrl: string) => ({ * `examples/app-showcase/src/system/self-url.ts` reads it; its one plugin * contributes the `fxp` provider in `init()` (before the automation service * materializes in `start()`), and the factory writes what it was handed to - * the project directory, the `serve` child's cwd. + * the project directory, the `serve` child's cwd. Its one action, `ping`, + * dials `/api/v1/health` at that `baseUrl` and fails on anything but a 2xx, + * so `PING_FLOW` succeeds only when the `baseUrl` is the app's own address. */ const CONFIG_SOURCE = `import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -150,10 +172,34 @@ export default defineStack({ init: async (ctx: any) => { ctx.getService('automation').registerConnectorProvider('fxp', (c: any) => { writeFileSync(join(process.cwd(), '${MATERIALIZED_FILE}'), JSON.stringify(c.providerConfig)); - return { def: { name: c.name, label: c.label, type: c.type, actions: [] }, handlers: {} }; + return { + def: { name: c.name, label: c.label, type: c.type, actions: [{ key: 'ping', label: 'Ping' }] }, + handlers: { + ping: async () => { + const r = await fetch(\`\${c.providerConfig.baseUrl}/api/v1/health\`, { signal: AbortSignal.timeout(5000) }); + if (!r.ok) throw new Error(\`health answered \${r.status}\`); + return { status: r.status }; + }, + }, + }; }); }, }], + flows: [{ + name: '${PING_FLOW}', + label: 'Ping', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'ping', type: 'connector_action', label: 'Ping', connectorConfig: { connectorId: '${CONNECTOR}', actionId: 'ping', input: {} } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ping' }, + { id: 'e2', source: 'ping', target: 'end' }, + ], + }], }); `; @@ -162,6 +208,8 @@ interface Reading { port?: number; served?: unknown; materialized?: unknown; + /** The status and body `POST /api/v1/automation/PING_FLOW/trigger` answered. */ + ping?: { status: number; body: unknown }; error?: Error; } @@ -169,6 +217,7 @@ const groups: ChildProcess[] = []; let dir: string; let devReading: Reading = {}; let serveReading: Reading = {}; +let shiftReading: Reading = {}; interface Live { child: ChildProcess; @@ -178,9 +227,16 @@ interface Live { /** * Boot `bin/run.js ARGS` asking for `port`, resolved once the banner tail shows - * the child listening on exactly that port. + * the child listening on exactly that port; or, with `shiftsOff` (the port is + * held), on the port its banner names, which must be another one. */ -function boot(what: string, args: string[], port: number, env: Record): Promise { +function boot( + what: string, + args: string[], + port: number, + env: Record, + shiftsOff = false, +): Promise { return new Promise((resolveBoot, rejectBoot) => { const child = spawn( process.execPath, @@ -224,8 +280,16 @@ function boot(what: string, args: string[], port: number, env: Record { out += String(d); + if (!READY.test(out)) return; + if (shiftsOff) { + const readback = boundPortFromBanner(out); + settle(readback.state === 'bound' && readback.port !== port + ? readback.port + : new Error(`${what} held ${port} yet its banner reads ${JSON.stringify(readback)}\n--- output ---\n${out.slice(-4000)}`)); + return; + } // A boot on another port than the drawn one lost the draw's race; say so. - if (READY.test(out)) settle(portDriftError(out, what, port) ?? port); + settle(portDriftError(out, what, port) ?? port); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); @@ -346,8 +410,39 @@ beforeAll(async () => { devReading = { error: error instanceof Error ? error : new Error(String(error)) }; } finally { if (live) await stopGroup(live.child); + live = undefined; + rmSync(materializedPath, { force: true }); + } + + // `os dev -p N` with N held for the whole boot: the server shifts off it. + const held = await holdPort(); + try { + live = await boot( + 'os dev -p (held)', + ['dev', '--no-watch', '--no-restart', '-d', 'file:shift.db', '-p', String(held.port)], + held.port, + {}, + true, + ); + const port = Number(new URL(live.base).port); + const signIn = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD }); + const token = signIn.body?.token; + if (signIn.status !== 200 || typeof token !== 'string') { + throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${live.output().slice(-3000)}`); + } + const meta = await http(live, 'GET', `/api/v1/meta/connector/${CONNECTOR}`, token); + if (meta.status !== 200) { + throw new Error(`GET /api/v1/meta/connector/${CONNECTOR} answered ${meta.status}: ${JSON.stringify(meta.body)}`); + } + const ping = await http(live, 'POST', `/api/v1/automation/${PING_FLOW}/trigger`, token, {}); + shiftReading = { port, served: servedBaseUrl(meta.body), materialized: materialized(live), ping }; + } catch (error) { + shiftReading = { error: error instanceof Error ? error : new Error(String(error)) }; + } finally { + if (live) await stopGroup(live.child); + await held.release(); } -}, 2 * BOOT_TIMEOUT_MS + 60_000); +}, 3 * BOOT_TIMEOUT_MS + 60_000); afterAll(async () => { for (const child of groups) await stopGroup(child); @@ -377,3 +472,20 @@ describe('#26243 — `os serve --port N` with no OS_PORT', () => { expect(materialized).toBe(selfUrlFor(port)); }); }); + +describe('#26270 — `os dev -p N` with N held serves, dispatches and pings on the port it shifted to', () => { + it('dispatch materializes the connector on the bound port, not the held one', () => { + const { port, materialized } = recorded(shiftReading); + expect(materialized).toBe(selfUrlFor(port)); + }); + + it('/api/v1/meta/connector/NAME serves that same baseUrl', () => { + const { port, served } = recorded(shiftReading); + expect(served).toBe(selfUrlFor(port)); + }); + + it('a flow pinging the app through the connector succeeds', () => { + const { ping } = recorded(shiftReading); + expect(ping.status, JSON.stringify(ping.body)).toBe(200); + }); +}); From bc2d71ed0030cd06b12671980d059dde242cb238 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:48:18 +0000 Subject: [PATCH 2/5] 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 Claude-Session: https://claude.ai/code/session_01NQ4sTujBVMFFGmdXcRmdd5 --- packages/cli/src/commands/dev.ts | 45 +++++++++++++++++++++++-- packages/cli/src/commands/serve.ts | 38 ++++++++++++++++----- packages/cli/src/utils/port-contract.ts | 4 +++ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 6d3dd0ca09d..16f0b6e3621 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -53,7 +53,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'; /** @@ -516,7 +519,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 — @@ -572,6 +575,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`). @@ -822,7 +862,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 diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e437d84994d..384cd26a98a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -467,6 +467,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). @@ -2340,14 +2360,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 diff --git a/packages/cli/src/utils/port-contract.ts b/packages/cli/src/utils/port-contract.ts index 737cbdb9192..7ce22c664d6 100644 --- a/packages/cli/src/utils/port-contract.ts +++ b/packages/cli/src/utils/port-contract.ts @@ -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 From 774eb26bd53e16936537c0fd7d9ea92dc73fcee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:17:42 +0000 Subject: [PATCH 3/5] test(cli): hold the busy port with an HTTP neighbour that answers, so the ping fails fast and the holder releases Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NQ4sTujBVMFFGmdXcRmdd5 --- ...v-connector-env-parity.integration.test.ts | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/dev-connector-env-parity.integration.test.ts b/packages/cli/test/dev-connector-env-parity.integration.test.ts index f98d26e8697..e28fd3d39de 100644 --- a/packages/cli/test/dev-connector-env-parity.integration.test.ts +++ b/packages/cli/test/dev-connector-env-parity.integration.test.ts @@ -62,7 +62,8 @@ * now runs the child's walk before it compiles and exports the port it lands * on (`portFlagEnv`). * - * So the third boot here holds a port for its whole life (`holdPort`), asks + * So the third boot here holds a port for its whole life (an HTTP neighbour + * answering 503, see {@link holdPortWithNeighbour}), asks * `os dev -p` for it, and reads the shifted port out of the child's own * banner. The served and the materialized `baseUrl` must both name that port, * and a flow pinging the app through the connector (`fxp_ping`, the fixture's @@ -73,7 +74,8 @@ * `bin/run.js` with `NODE_ENV` unset, the shipped entry resolving commands from * `dist/` (`requireBuiltCli`), in its own process group (`dev` supervises a * `serve` grandchild). Each boot asks for a NON-ZERO port drawn with - * `reservePort()` (the busy-port boot holds its own with `holdPort()`), because that number is the subject: the config must read + * `reservePort()` (the busy-port boot holds its own instead), because that + * number is the subject: the config must read * the port the flag names, before the boot, and a kernel-assigned `-p 0` read * back afterwards cannot be known to the config (the draw is ledgered in * `released-port-production-spawn.pin.test.ts`). A lost race on that draw is @@ -84,6 +86,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawn, type ChildProcess } from 'node:child_process'; +import { createServer as createHttpServer } from 'node:http'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -93,7 +96,6 @@ import { childEnv, E2E_AUTH_SECRET, E2E_SECRET_KEY, - holdPort, portContentionError, portDriftError, probeThroughChild, @@ -302,6 +304,38 @@ function boot( }); } +/** + * Hold a port for a whole boot with a neighbour that ANSWERS: an HTTP server + * replying 503 to everything, as another app on that port would. + * + * ⛔ Not `holdPort()` from the shared helper. Its bare socket never answers, so + * a ping dialing it waits out the handler's timeout, and its `release()` then + * waits forever on the connection the ping left behind (measured: the socket + * sat in CLOSE_WAIT and the hook timed out at 600 s). + */ +function holdPortWithNeighbour(): Promise<{ port: number; release: () => Promise }> { + return new Promise((resolveHold, rejectHold) => { + const server = createHttpServer((_req, res) => { + res.writeHead(503, { connection: 'close' }).end('not this app'); + }); + server.on('error', rejectHold); + server.listen(0, '0.0.0.0', () => { + const address = server.address(); + if (address === null || typeof address === 'string') { + rejectHold(new Error(`listen(0) produced no numeric address: ${String(address)}`)); + return; + } + resolveHold({ + port: address.port, + release: () => new Promise((done) => { + server.closeAllConnections(); + server.close(() => done()); + }), + }); + }); + }); +} + async function stopGroup(child: ChildProcess): Promise { if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; await new Promise((done) => { @@ -415,7 +449,7 @@ beforeAll(async () => { } // `os dev -p N` with N held for the whole boot: the server shifts off it. - const held = await holdPort(); + const held = await holdPortWithNeighbour(); try { live = await boot( 'os dev -p (held)', From 4cb7cd35b1f0878313646cd95590c2bcf166dcf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:24:12 +0000 Subject: [PATCH 4/5] chore(changeset): cli patch for the dev auto-shift reaching the config Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NQ4sTujBVMFFGmdXcRmdd5 --- .changeset/26270-dev-port-shift-reaches-config.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/26270-dev-port-shift-reaches-config.md diff --git a/.changeset/26270-dev-port-shift-reaches-config.md b/.changeset/26270-dev-port-shift-reaches-config.md new file mode 100644 index 00000000000..a6b64aaf48d --- /dev/null +++ b/.changeset/26270-dev-port-shift-reaches-config.md @@ -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. The same boots now serve and ping `:41311`, 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. From 42bd366894ee086ac41f19765db65e53372102e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:42:28 +0000 Subject: [PATCH 5/5] chore(changeset): name the direct os serve --dev compile in the dev auto-shift note Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NQ4sTujBVMFFGmdXcRmdd5 --- .changeset/26270-dev-port-shift-reaches-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/26270-dev-port-shift-reaches-config.md b/.changeset/26270-dev-port-shift-reaches-config.md index a6b64aaf48d..3ec49f97a18 100644 --- a/.changeset/26270-dev-port-shift-reaches-config.md +++ b/.changeset/26270-dev-port-shift-reaches-config.md @@ -8,5 +8,5 @@ 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. The same boots now serve and ping `:41311`, and the ping answers 200. +- **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.