5252 * module is the only copy), has no `OS_PORT` at all, and the materialized
5353 * `baseUrl` must read N.
5454 *
55+ * ## #26270 — a busy port's shift is the port the config reads
56+ *
57+ * `os dev` shifts off a busy port, but the shift happened in the `serve`
58+ * child after the parent's compile and the child's config load had both read
59+ * the requested port. Measured on the showcase with 41310 held: `os dev -p
60+ * 41310` served on 41311, `/api/v1/meta/connector/showcase_status_api` named
61+ * 41310 and the self-ping flow answered `400 FLOW_FAILED`. The `os dev` parent
62+ * now runs the child's walk before it compiles and exports the port it lands
63+ * on (`portFlagEnv`).
64+ *
65+ * So the third boot here holds a port for its whole life (`holdPort`), asks
66+ * `os dev -p` for it, and reads the shifted port out of the child's own
67+ * banner. The served and the materialized `baseUrl` must both name that port,
68+ * and a flow pinging the app through the connector (`fxp_ping`, the fixture's
69+ * `showcase_declarative_connector_ping`) must succeed there.
70+ *
5571 * ## Spawn shape
5672 *
5773 * `bin/run.js` with `NODE_ENV` unset, the shipped entry resolving commands from
5874 * `dist/` (`requireBuiltCli`), in its own process group (`dev` supervises a
5975 * `serve` grandchild). Each boot asks for a NON-ZERO port drawn with
60- * `reservePort()`, because that number is the subject: the config must read
76+ * `reservePort()` (the busy-port boot holds its own with `holdPort()`) , because that number is the subject: the config must read
6177 * the port the flag names, before the boot, and a kernel-assigned `-p 0` read
6278 * back afterwards cannot be known to the config (the draw is ledgered in
6379 * `released-port-production-spawn.pin.test.ts`). A lost race on that draw is
@@ -73,9 +89,11 @@ import { tmpdir } from 'node:os';
7389import { join , resolve } from 'node:path' ;
7490import { fileURLToPath } from 'node:url' ;
7591import {
92+ boundPortFromBanner ,
7693 childEnv ,
7794 E2E_AUTH_SECRET ,
7895 E2E_SECRET_KEY ,
96+ holdPort ,
7997 portContentionError ,
8098 portDriftError ,
8199 probeThroughChild ,
@@ -101,6 +119,8 @@ const selfUrlFor = (port: number | string) => `http://127.0.0.1:${port}`;
101119const CONNECTOR = 'fxp_self' ;
102120/** Where the fixture provider's factory records the `providerConfig` it was handed. */
103121const MATERIALIZED_FILE = 'materialized-provider-config.json' ;
122+ /** The flow that pings the app through the connector, as the showcase's self-ping does. */
123+ const PING_FLOW = 'fxp_ping' ;
104124/** The development dev-admin seed — the operator on this boot. */
105125const EMAIL = 'admin@objectos.ai' ;
106126const PASSWORD = 'admin123' ;
@@ -130,7 +150,9 @@ const connector = (baseUrl: string) => ({
130150 * `examples/app-showcase/src/system/self-url.ts` reads it; its one plugin
131151 * contributes the `fxp` provider in `init()` (before the automation service
132152 * materializes in `start()`), and the factory writes what it was handed to
133- * the project directory, the `serve` child's cwd.
153+ * the project directory, the `serve` child's cwd. Its one action, `ping`,
154+ * dials `/api/v1/health` at that `baseUrl` and fails on anything but a 2xx,
155+ * so `PING_FLOW` succeeds only when the `baseUrl` is the app's own address.
134156 */
135157const CONFIG_SOURCE = `import { writeFileSync } from 'node:fs';
136158import { join } from 'node:path';
@@ -150,10 +172,34 @@ export default defineStack({
150172 init: async (ctx: any) => {
151173 ctx.getService('automation').registerConnectorProvider('fxp', (c: any) => {
152174 writeFileSync(join(process.cwd(), '${ MATERIALIZED_FILE } '), JSON.stringify(c.providerConfig));
153- return { def: { name: c.name, label: c.label, type: c.type, actions: [] }, handlers: {} };
175+ return {
176+ def: { name: c.name, label: c.label, type: c.type, actions: [{ key: 'ping', label: 'Ping' }] },
177+ handlers: {
178+ ping: async () => {
179+ const r = await fetch(\`\${c.providerConfig.baseUrl}/api/v1/health\`, { signal: AbortSignal.timeout(5000) });
180+ if (!r.ok) throw new Error(\`health answered \${r.status}\`);
181+ return { status: r.status };
182+ },
183+ },
184+ };
154185 });
155186 },
156187 }],
188+ flows: [{
189+ name: '${ PING_FLOW } ',
190+ label: 'Ping',
191+ type: 'autolaunched',
192+ status: 'active',
193+ nodes: [
194+ { id: 'start', type: 'start', label: 'Start' },
195+ { id: 'ping', type: 'connector_action', label: 'Ping', connectorConfig: { connectorId: '${ CONNECTOR } ', actionId: 'ping', input: {} } },
196+ { id: 'end', type: 'end', label: 'End' },
197+ ],
198+ edges: [
199+ { id: 'e1', source: 'start', target: 'ping' },
200+ { id: 'e2', source: 'ping', target: 'end' },
201+ ],
202+ }],
157203});
158204` ;
159205
@@ -162,13 +208,16 @@ interface Reading {
162208 port ?: number ;
163209 served ?: unknown ;
164210 materialized ?: unknown ;
211+ /** The status and body `POST /api/v1/automation/PING_FLOW/trigger` answered. */
212+ ping ?: { status : number ; body : unknown } ;
165213 error ?: Error ;
166214}
167215
168216const groups : ChildProcess [ ] = [ ] ;
169217let dir : string ;
170218let devReading : Reading = { } ;
171219let serveReading : Reading = { } ;
220+ let shiftReading : Reading = { } ;
172221
173222interface Live {
174223 child : ChildProcess ;
@@ -178,9 +227,16 @@ interface Live {
178227
179228/**
180229 * Boot `bin/run.js ARGS` asking for `port`, resolved once the banner tail shows
181- * the child listening on exactly that port.
230+ * the child listening on exactly that port; or, with `shiftsOff` (the port is
231+ * held), on the port its banner names, which must be another one.
182232 */
183- function boot ( what : string , args : string [ ] , port : number , env : Record < string , string | undefined > ) : Promise < Live > {
233+ function boot (
234+ what : string ,
235+ args : string [ ] ,
236+ port : number ,
237+ env : Record < string , string | undefined > ,
238+ shiftsOff = false ,
239+ ) : Promise < Live > {
184240 return new Promise ( ( resolveBoot , rejectBoot ) => {
185241 const child = spawn (
186242 process . execPath ,
@@ -224,8 +280,16 @@ function boot(what: string, args: string[], port: number, env: Record<string, st
224280 ) ;
225281 const onData = ( d : unknown ) => {
226282 out += String ( d ) ;
283+ if ( ! READY . test ( out ) ) return ;
284+ if ( shiftsOff ) {
285+ const readback = boundPortFromBanner ( out ) ;
286+ settle ( readback . state === 'bound' && readback . port !== port
287+ ? readback . port
288+ : new Error ( `${ what } held ${ port } yet its banner reads ${ JSON . stringify ( readback ) } \n--- output ---\n${ out . slice ( - 4000 ) } ` ) ) ;
289+ return ;
290+ }
227291 // A boot on another port than the drawn one lost the draw's race; say so.
228- if ( READY . test ( out ) ) settle ( portDriftError ( out , what , port ) ?? port ) ;
292+ settle ( portDriftError ( out , what , port ) ?? port ) ;
229293 } ;
230294 child . stdout ?. on ( 'data' , onData ) ;
231295 child . stderr ?. on ( 'data' , onData ) ;
@@ -346,8 +410,39 @@ beforeAll(async () => {
346410 devReading = { error : error instanceof Error ? error : new Error ( String ( error ) ) } ;
347411 } finally {
348412 if ( live ) await stopGroup ( live . child ) ;
413+ live = undefined ;
414+ rmSync ( materializedPath , { force : true } ) ;
415+ }
416+
417+ // `os dev -p N` with N held for the whole boot: the server shifts off it.
418+ const held = await holdPort ( ) ;
419+ try {
420+ live = await boot (
421+ 'os dev -p (held)' ,
422+ [ 'dev' , '--no-watch' , '--no-restart' , '-d' , 'file:shift.db' , '-p' , String ( held . port ) ] ,
423+ held . port ,
424+ { } ,
425+ true ,
426+ ) ;
427+ const port = Number ( new URL ( live . base ) . port ) ;
428+ const signIn = await http ( live , 'POST' , '/api/v1/auth/sign-in/email' , '' , { email : EMAIL , password : PASSWORD } ) ;
429+ const token = signIn . body ?. token ;
430+ if ( signIn . status !== 200 || typeof token !== 'string' ) {
431+ throw new Error ( `sign-in answered ${ signIn . status } : ${ JSON . stringify ( signIn . body ) } \n--- output ---\n${ live . output ( ) . slice ( - 3000 ) } ` ) ;
432+ }
433+ const meta = await http ( live , 'GET' , `/api/v1/meta/connector/${ CONNECTOR } ` , token ) ;
434+ if ( meta . status !== 200 ) {
435+ throw new Error ( `GET /api/v1/meta/connector/${ CONNECTOR } answered ${ meta . status } : ${ JSON . stringify ( meta . body ) } ` ) ;
436+ }
437+ const ping = await http ( live , 'POST' , `/api/v1/automation/${ PING_FLOW } /trigger` , token , { } ) ;
438+ shiftReading = { port, served : servedBaseUrl ( meta . body ) , materialized : materialized ( live ) , ping } ;
439+ } catch ( error ) {
440+ shiftReading = { error : error instanceof Error ? error : new Error ( String ( error ) ) } ;
441+ } finally {
442+ if ( live ) await stopGroup ( live . child ) ;
443+ await held . release ( ) ;
349444 }
350- } , 2 * BOOT_TIMEOUT_MS + 60_000 ) ;
445+ } , 3 * BOOT_TIMEOUT_MS + 60_000 ) ;
351446
352447afterAll ( async ( ) => {
353448 for ( const child of groups ) await stopGroup ( child ) ;
@@ -377,3 +472,20 @@ describe('#26243 — `os serve --port N` with no OS_PORT', () => {
377472 expect ( materialized ) . toBe ( selfUrlFor ( port ) ) ;
378473 } ) ;
379474} ) ;
475+
476+ describe ( '#26270 — `os dev -p N` with N held serves, dispatches and pings on the port it shifted to' , ( ) => {
477+ it ( 'dispatch materializes the connector on the bound port, not the held one' , ( ) => {
478+ const { port, materialized } = recorded ( shiftReading ) ;
479+ expect ( materialized ) . toBe ( selfUrlFor ( port ) ) ;
480+ } ) ;
481+
482+ it ( '/api/v1/meta/connector/NAME serves that same baseUrl' , ( ) => {
483+ const { port, served } = recorded ( shiftReading ) ;
484+ expect ( served ) . toBe ( selfUrlFor ( port ) ) ;
485+ } ) ;
486+
487+ it ( 'a flow pinging the app through the connector succeeds' , ( ) => {
488+ const { ping } = recorded ( shiftReading ) ;
489+ expect ( ping . status , JSON . stringify ( ping . body ) ) . toBe ( 200 ) ;
490+ } ) ;
491+ } ) ;
0 commit comments