@@ -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' ;
5760import 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
0 commit comments