From 4fb88734ca359d2f69b59e965d431dd284844ca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 20:01:01 +0000 Subject: [PATCH] test(cli): every bespoke CLI spawner takes a kernel-assigned port and reads it back Slice 2 of #24342. The 23 development-column files that spawned the CLI themselves on a released port now spawn on KERNEL_ASSIGNED_PORT and read the bound port back off the child's own output; each boot builds its base URL from its own read-back. The dead local drift checks go with them. The two draws that are an arm's subject stay ledgered with their real reasons. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- ...tifact-flag-precedence.integration.test.ts | 22 ++--- .../dev-boot-output-order.integration.test.ts | 5 +- packages/cli/test/helpers/serve-process.ts | 28 +++--- ...stall-local-boot-steps.integration.test.ts | 29 +++--- ...install-local-handlers.integration.test.ts | 35 ++++--- ...ge-install-local-hooks.integration.test.ts | 27 +++--- ...nstall-local-jobs-pull.integration.test.ts | 27 +++--- ...local-jobs-shared-name.integration.test.ts | 27 +++--- ...age-install-local-jobs.integration.test.ts | 29 +++--- ...cal-uninstall-cleanups.integration.test.ts | 39 ++++---- ...age-restart-acceptance.integration.test.ts | 38 ++++---- ...released-port-production-spawn.pin.test.ts | 55 ++++------- ...e-app-anchored-optional-import.e2e.test.ts | 96 ++++++------------- ...-config-boot-flat-docs.integration.test.ts | 29 +++--- ...e-config-boot-residual.integration.test.ts | 32 +++---- ...t-config-metadata-door.integration.test.ts | 27 +++--- ...serve-mcp-capability-collision.e2e.test.ts | 79 ++++----------- .../test/serve-mcp-stdio-answers.e2e.test.ts | 81 +++++----------- ...ate-exit-schema-parity.integration.test.ts | 4 +- ...serve-organization-record-page.e2e.test.ts | 29 +++--- .../test/serve-process-child-env.e2e.test.ts | 18 ++-- ...untime-assets-branding-warning.e2e.test.ts | 26 ++--- .../serve-stdio-stdout-purity.e2e.test.ts | 81 +++++----------- ...ve-view-container-read.integration.test.ts | 29 +++--- .../test/start-signal-forwarding.e2e.test.ts | 29 +++--- 25 files changed, 349 insertions(+), 572 deletions(-) diff --git a/packages/cli/test/artifact-flag-precedence.integration.test.ts b/packages/cli/test/artifact-flag-precedence.integration.test.ts index e8f54f7c28a..54e668ec2cc 100644 --- a/packages/cli/test/artifact-flag-precedence.integration.test.ts +++ b/packages/cli/test/artifact-flag-precedence.integration.test.ts @@ -81,10 +81,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; import { defineStackSourceFromLiteral, linkSpec, writeDefineStackConfig } from './helpers/define-stack-fixture.js'; @@ -125,7 +124,7 @@ interface Live { output: () => string; } -function boot(argv: string[], cwd: string, port: string, env: Record): Promise { +function boot(argv: string[], cwd: string, env: Record): Promise { return new Promise((resolveBoot, rejectBoot) => { const child = spawn(TSX, [CLI, ...argv], { cwd, @@ -147,12 +146,12 @@ function boot(argv: string[], cwd: string, port: string, env: Record { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`${argv[0]} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -160,13 +159,13 @@ function boot(argv: string[], cwd: string, port: string, env: Record { out += String(d); - if (READY.test(out)) settle(portDriftError(out, `os ${argv[0]}`, port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, `os ${argv[0]}`)); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, `os ${argv[0]}`, port) - ?? new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -250,10 +249,9 @@ interface Reading { } async function measure(argv: string[], cwd: string, env: Record = {}): Promise { - const port = randomPort(); let live: Live | undefined; try { - live = await boot([...argv.slice(0, 1), '-p', port, ...argv.slice(1)], cwd, port, env); + live = await boot([...argv.slice(0, 1), '-p', KERNEL_ASSIGNED_PORT, ...argv.slice(1)], cwd, env); 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') { diff --git a/packages/cli/test/dev-boot-output-order.integration.test.ts b/packages/cli/test/dev-boot-output-order.integration.test.ts index 60d9817beeb..6dbe736040f 100644 --- a/packages/cli/test/dev-boot-output-order.integration.test.ts +++ b/packages/cli/test/dev-boot-output-order.integration.test.ts @@ -58,7 +58,7 @@ import { fileURLToPath } from 'node:url'; import { childEnv, E2E_SECRET_KEY, - randomPort, + KERNEL_ASSIGNED_PORT, requireBuiltCli, RUN_JS_RESOLVES_FROM_DIST, } from './helpers/serve-process.js'; @@ -115,10 +115,9 @@ function screenLines(raw: string): string[] { /** Boot `os dev` under a pty until the banner and the block have both printed, then Ctrl+C it. */ function bootUnderPty(boot: number): Promise { - const port = randomPort(); const command = [ `exec '${process.execPath}' '${CLI}' dev --no-watch`, - `-d 'file:boot${boot}.db' -p ${port}`, + `-d 'file:boot${boot}.db' -p ${KERNEL_ASSIGNED_PORT}`, ].join(' '); return new Promise((resolveBoot, rejectBoot) => { const child = spawn('script', ['-qfec', command, '/dev/null'], { diff --git a/packages/cli/test/helpers/serve-process.ts b/packages/cli/test/helpers/serve-process.ts index fc6e05c4ffb..0345dcee422 100644 --- a/packages/cli/test/helpers/serve-process.ts +++ b/packages/cli/test/helpers/serve-process.ts @@ -182,8 +182,8 @@ function probeBind(want: number): number | null { /** * The released-port draw — a real bind probe, not a blind `Math.random()` - * (#12441) — for the spawns that still take a number. ⛔ NOT for a spawn that - * can reach production posture: those take {@link KERNEL_ASSIGNED_PORT}. + * (#12441) — for the few sites that still take a number. ⛔ NOT for a CLI + * spawn, in either posture: those take {@link KERNEL_ASSIGNED_PORT}. * * Listen on `0.0.0.0:0`, read the port the kernel assigned, close the listener, * return the port for the caller to hand to `os serve`. @@ -224,21 +224,20 @@ function probeBind(want: number): number | null { * development-posture child hops instead, and `portDriftError()` reds the hop * just as surely, so that column closes the same way: `runServe()` hands its * child a kernel-assigned port whenever the caller passes no `--port`, and - * returns the port the child bound (#24342). + * returns the port the child bound; every bespoke spawner reads its own + * kernel-assigned port back the same way (#24342). * `released-port-production-spawn.pin.test.ts` holds both: a file that spawns * the CLI may call this or `randomPort()` only as often as its ledger entry * there allows — zero without one. * * What may still call it, each with the race still real and unclosed there: * - * • the development-column spawners slice 2 of #24342 has yet to convert — - * the files that spawn `CLI` themselves (the `package-install-local-*` - * `os start` family among them) and the four `bin/run.js --dev` / `os dev` - * files, each ledgered in the pin with its exact count. A lost race there - * is a hop to the next port, and `portDriftError()` below turns it into a - * red on a correct tree; * • instruments that need a number NOTHING is listening on (a closed-port - * probe), or that measure this draw itself (`serve-port-bind-probe`). + * probe), or that measure this draw itself (`serve-port-bind-probe`); + * • a CLI spawn whose SUBJECT is a non-zero request — a boot that must bind + * exactly the port it asked for, which `--port 0` cannot ask. The pin's + * ledger names each such file with its reason and exact count; a lost + * race there is the residual this draw narrows and cannot close. * * `portContentionError()` below is what makes a residual loss SAY it is a port * race instead of `serve exited 1 before "Server is ready"`. @@ -318,8 +317,8 @@ export function holdPort(): Promise<{ port: number; release: () => Promise * ⚠️ The name is kept — and is now a slight misnomer, which is cheaper than the * alternative. It is `String(reservePort())` and nothing else; the draw, the * guarantees, the residual race and the callers it may still serve - * (instruments, and the development-column files the pin's ledger names until - * slice 2 of #24342 converts them — never another CLI spawn) are all + * (instruments, and the few CLI spawns the pin's ledger names with their + * reason — never another CLI spawn) are all * documented on `reservePort()` above, and there is no second mechanism hiding * behind this name. */ @@ -599,8 +598,9 @@ export function boundPortFromBanner(output: string): BannerPortReadback { * the port between this harness's bind probe and the child's `listen()`. The * production column would die loudly, which is why it no longer draws (it takes * {@link KERNEL_ASSIGNED_PORT}, #24295); the development column drifts and gets - * this. `runServe()` without a `--port` no longer draws either (#24342); the - * spawners still ledgered in the pin do, and this is what makes their race a red. + * this. `runServe()` without a `--port` no longer draws either, nor does any + * bespoke spawner (#24342): what reaches this now is an EXPLICIT port — one held + * on purpose to force the drift, or a draw the pin's ledger names. * * ## Why `unreadable` is an ERROR and not a skip * diff --git a/packages/cli/test/package-install-local-boot-steps.integration.test.ts b/packages/cli/test/package-install-local-boot-steps.integration.test.ts index 21561e5c173..ea40dbdb8df 100644 --- a/packages/cli/test/package-install-local-boot-steps.integration.test.ts +++ b/packages/cli/test/package-install-local-boot-steps.integration.test.ts @@ -55,10 +55,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; @@ -153,9 +152,9 @@ interface LiveStart { output: () => string; } -function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { +function bootStart(cwd: string, home: string, extra: string[] = []): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { cwd, // `childEnv`, never a bare `...process.env` — see its header. env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }), @@ -166,12 +165,12 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] groups.push(child); let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -179,14 +178,13 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] ); const onData = (d: unknown) => { out += String(d); - // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start')); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, 'os start', port) - ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -320,24 +318,23 @@ beforeAll(async () => { const hostArtifact = join(runtimeDir, 'host.json'); writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8'); const home = join(runtimeDir, 'home'); - const port = randomPort(); // ── boot 1: the host, hot install, probe ─────────────────────────────── - const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const first = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token = await authenticate(first); installs.push(await packageInstall(appDir, first)); phases.install = await probe(first, token); await stopGroup(first.child); // ── boot 2: same host, home and cwd — the ledger rehydrates on kernel:ready ── - const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); phases.restart = await probe(second, await authenticate(second)); await stopGroup(second.child); // ── boot 3: the CONTROL — the same file as the boot artifact ─────────── const controlDir = join(root, 'control'); mkdirSync(controlDir, { recursive: true }); - const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]); + const third = await bootStart(controlDir, join(controlDir, 'home'), ['--artifact', join(appDir, 'dist', 'objectstack.json')]); phases.control = await probe(third, await authenticate(third)); await stopGroup(third.child); }, 4 * BOOT_TIMEOUT_MS); diff --git a/packages/cli/test/package-install-local-handlers.integration.test.ts b/packages/cli/test/package-install-local-handlers.integration.test.ts index eae5645676d..f172da69d64 100644 --- a/packages/cli/test/package-install-local-handlers.integration.test.ts +++ b/packages/cli/test/package-install-local-handlers.integration.test.ts @@ -40,9 +40,9 @@ * * The tsx source entry (`bin/run-dev.js`), as every `runServe()` caller spawns * it, for `os start` and for `os package install` alike. It pins - * `NODE_ENV=development`, which lets `serve` auto-shift off a port taken - * between the probe and the bind, so the ready banner is read back - * (`portDriftError`) before any request is addressed to the port. The CLI runs + * `NODE_ENV=development`, where a taken port is a hop rather than an exit, so + * each boot takes a kernel-assigned port and reads it back off its own ready + * banner (`readKernelAssignedPort`) before any request is addressed to it. The CLI runs * from `src/`, but every workspace package it loads — `@objectstack/runtime` * and `@objectstack/cloud-connection` included — resolves through its * `exports` to `dist/`: an ablation of either package's source reaches this @@ -60,10 +60,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; @@ -142,9 +141,9 @@ interface LiveStart { output: () => string; } -function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { +function bootStart(cwd: string, home: string, extra: string[] = []): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { cwd, // `childEnv`, never a bare `...process.env` — see its header. env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }), @@ -155,12 +154,12 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] groups.push(child); let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -168,14 +167,13 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] ); const onData = (d: unknown) => { out += String(d); - // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start')); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, 'os start', port) - ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -327,10 +325,9 @@ beforeAll(async () => { const runtimeDir = join(root, 'runtime'); mkdirSync(runtimeDir, { recursive: true }); const home = join(runtimeDir, 'home'); - const port = randomPort(); // ── boot 1: empty `os start`, install, probe, reinstall, probe ───────── - const first = await bootStart(runtimeDir, home, port); + const first = await bootStart(runtimeDir, home); const token = await authenticate(first); installs.push(await packageInstall(appDir, first)); phases.install = await probe(first, token); @@ -339,14 +336,14 @@ beforeAll(async () => { await stopGroup(first.child); // ── boot 2: same home and cwd — the ledger rehydrates on kernel:ready ── - const second = await bootStart(runtimeDir, home, port); + const second = await bootStart(runtimeDir, home); phases.restart = await probe(second, await authenticate(second)); await stopGroup(second.child); // ── boot 3: the CONTROL — the same file as the boot artifact ─────────── const controlDir = join(root, 'control'); mkdirSync(controlDir, { recursive: true }); - const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]); + const third = await bootStart(controlDir, join(controlDir, 'home'), ['--artifact', join(appDir, 'dist', 'objectstack.json')]); phases.control = await probe(third, await authenticate(third)); await stopGroup(third.child); }, 4 * BOOT_TIMEOUT_MS); diff --git a/packages/cli/test/package-install-local-hooks.integration.test.ts b/packages/cli/test/package-install-local-hooks.integration.test.ts index c413e5b7d5f..b780f86ebb0 100644 --- a/packages/cli/test/package-install-local-hooks.integration.test.ts +++ b/packages/cli/test/package-install-local-hooks.integration.test.ts @@ -52,10 +52,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; @@ -213,9 +212,9 @@ interface LiveStart { output: () => string; } -function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { +function bootStart(cwd: string, home: string, extra: string[] = []): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { cwd, // `childEnv`, never a bare `...process.env` — see its header. Package // scheduled work is OFF by default in every posture (#17396); this @@ -234,12 +233,12 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] groups.push(child); let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -247,14 +246,13 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] ); const onData = (d: unknown) => { out += String(d); - // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start')); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, 'os start', port) - ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -397,10 +395,9 @@ beforeAll(async () => { writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8'); writeFileSync(join(runtimeDir, 'runtime.mjs'), HOST_RUNTIME_MODULE, 'utf8'); const home = join(runtimeDir, 'home'); - const port = randomPort(); // ── boot 1: the host; hot installs ───────────────────────────────────── - const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const first = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token = await authenticate(first); readings.handlerInstall = await packageInstall(apps.handler, first); readings.bodyInstall = await packageInstall(apps.body, first); @@ -419,7 +416,7 @@ beforeAll(async () => { const ledger = join(runtimeDir, '.objectstack', 'installed-packages'); mkdirSync(ledger, { recursive: true }); writeFileSync(join(ledger, `${LEGACY_APP_ID}.json`), JSON.stringify(LEGACY_ENTRY, null, 2), 'utf8'); - const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token2 = await authenticate(second); readings.legacyInsert = await http(second, 'POST', `/api/v1/data/${LEGACY_TASK}`, token2, { name: 'legacy' }); readings.output.restart = second.output(); diff --git a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts index fb14fe43a9e..2f99e61de3a 100644 --- a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts @@ -57,10 +57,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; @@ -180,9 +179,9 @@ interface LiveStart { output: () => string; } -function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { +function bootStart(cwd: string, home: string, extra: string[] = []): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { cwd, // `childEnv`, never a bare `...process.env` — see its header. Package // scheduled work is OFF by default in every posture (#17396); this @@ -201,12 +200,12 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] groups.push(child); let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -214,14 +213,13 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] ); const onData = (d: unknown) => { out += String(d); - // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start')); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, 'os start', port) - ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -370,10 +368,9 @@ beforeAll(async () => { const hostArtifact = join(runtimeDir, 'host.json'); writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8'); const home = join(runtimeDir, 'home'); - const port = randomPort(); // ── boot 1: the host; hot installs ───────────────────────────────────── - const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const first = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token = await authenticate(first); readings.missingInstall = await packageInstall(apps.missing, first); readings.nosourceInstall = await packageInstall(apps.nosource, first); @@ -392,7 +389,7 @@ beforeAll(async () => { const ledger = join(runtimeDir, '.objectstack', 'installed-packages'); mkdirSync(ledger, { recursive: true }); writeFileSync(join(ledger, `${LEGACY_APP_ID}.json`), JSON.stringify(LEGACY_ENTRY, null, 2), 'utf8'); - const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token2 = await authenticate(second); readings.legacyBoundRuns = await awaitRuns(second, token2, LEGACY_BOUND); readings.legacyBoundJob = await jobRow(second, token2, LEGACY_BOUND); diff --git a/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts b/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts index afda51a8de0..d27004d168f 100644 --- a/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts @@ -53,10 +53,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; @@ -140,9 +139,9 @@ interface LiveStart { output: () => string; } -function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { +function bootStart(cwd: string, home: string, extra: string[] = []): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { cwd, // `childEnv`, never a bare `...process.env` — see its header. Package // scheduled work is OFF by default in every posture (#17396); this @@ -161,12 +160,12 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] groups.push(child); let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -174,14 +173,13 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] ); const onData = (d: unknown) => { out += String(d); - // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start')); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, 'os start', port) - ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -355,10 +353,9 @@ beforeAll(async () => { const hostArtifact = join(runtimeDir, 'host.json'); writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8'); const home = join(runtimeDir, 'home'); - const port = randomPort(); // ── boot 1: hot installs ───────────────────────────────────────────── - const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const first = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token = await authenticate(first); // ALPHA alone: a single-package runtime. @@ -386,7 +383,7 @@ beforeAll(async () => { await stopGroup(first.child); // ── boot 2: same host, home and cwd — the ledger rehydrates ALPHA and GAMMA ── - const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token2 = await authenticate(second); readings.alphaRestart = await awaitRuns(second, token2, ALPHA, await countOf(second, token2, ALPHA)); readings.gammaRestart = await awaitRuns(second, token2, GAMMA, await countOf(second, token2, GAMMA)); diff --git a/packages/cli/test/package-install-local-jobs.integration.test.ts b/packages/cli/test/package-install-local-jobs.integration.test.ts index f3f571aa3ed..3e3179f0b1e 100644 --- a/packages/cli/test/package-install-local-jobs.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs.integration.test.ts @@ -54,10 +54,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; @@ -199,9 +198,9 @@ interface LiveStart { output: () => string; } -function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise { +function bootStart(cwd: string, home: string, extra: string[] = []): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { + const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], { cwd, // `childEnv`, never a bare `...process.env` — see its header. Package // scheduled work is OFF by default in every posture (#17396); this @@ -220,12 +219,12 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] groups.push(child); let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -233,14 +232,13 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = [] ); const onData = (d: unknown) => { out += String(d); - // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start')); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, 'os start', port) - ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -412,10 +410,9 @@ beforeAll(async () => { const hostArtifact = join(runtimeDir, 'host.json'); writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8'); const home = join(runtimeDir, 'home'); - const port = randomPort(); // ── boot 1: the host, hot installs, the body job runs ───────────────── - const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const first = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token = await authenticate(first); readings.bodyInstall = await packageInstall(bodyApp, first); readings.otherInstall = await packageInstall(otherApp, first); @@ -440,7 +437,7 @@ beforeAll(async () => { await stopGroup(first.child); // ── boot 2: same host, home and cwd — the ledger rehydrates on kernel:ready ── - const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]); + const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]); const token2 = await authenticate(second); // The rows boot 1 left behind are the floors: only a run in THIS boot lifts one. const floorOf = async (object: string, job: string) => rowsOf(await jobRows(second, token2, object, job)).length; @@ -457,7 +454,7 @@ beforeAll(async () => { // ── boot 3: the CONTROL — both job forms in the boot artifact ───────── const controlHome = join(controlApp, 'home'); - const third = await bootStart(controlApp, controlHome, port, ['--artifact', join(controlApp, 'dist', 'objectstack.json')]); + const third = await bootStart(controlApp, controlHome, ['--artifact', join(controlApp, 'dist', 'objectstack.json')]); const token3 = await authenticate(third); readings.controlHandler = await awaitRuns(third, token3, HANDLER_TICK, HANDLER_JOB, 0); readings.controlBody = await awaitRuns(third, token3, BODY_TICK, BODY_JOB, 0); diff --git a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts index 8c7c8a56d29..78db7ca644f 100644 --- a/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts +++ b/packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts @@ -70,10 +70,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; @@ -130,9 +129,9 @@ interface LiveStart { output: () => string; } -function bootStart(cwd: string, home: string, port: string): Promise { +function bootStart(cwd: string, home: string): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui'], { + const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui'], { cwd, // `childEnv`, never a bare `...process.env` — see its header. env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }), @@ -143,12 +142,12 @@ function bootStart(cwd: string, home: string, port: string): Promise groups.push(child); let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -156,14 +155,13 @@ function bootStart(cwd: string, home: string, port: string): Promise ); const onData = (d: unknown) => { out += String(d); - // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, 'os start', port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start')); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, 'os start', port) - ?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -337,18 +335,17 @@ beforeAll(async () => { const otherAppDir = join(root, 'other-app'); mkdirSync(join(otherAppDir, 'dist'), { recursive: true }); writeFileSync(join(otherAppDir, 'dist', 'objectstack.json'), JSON.stringify(OTHER_ARTIFACT, null, 2), 'utf8'); - const port = randomPort(); // ── order 1: hot install → DELETE → restart ──────────────────────────── const hotDir = join(root, 'hot'); mkdirSync(hotDir, { recursive: true }); const hotHome = join(hotDir, 'home'); - const a = await bootStart(hotDir, hotHome, port); + const a = await bootStart(hotDir, hotHome); const aSession = await authenticate(a); runs.hot.install = await packageInstall(appDir, a); await grantThenUninstall(a, aSession, runs.hot); await stopGroup(a.child); - const b = await bootStart(hotDir, hotHome, port); + const b = await bootStart(hotDir, hotHome); await readAfterRestart(b, runs.hot); await stopGroup(b.child); @@ -358,14 +355,14 @@ beforeAll(async () => { const coldDir = join(root, 'cold'); mkdirSync(coldDir, { recursive: true }); const coldHome = join(coldDir, 'home'); - const c = await bootStart(coldDir, coldHome, port); + const c = await bootStart(coldDir, coldHome); await authenticate(c); runs.restarted.install = await packageInstall(appDir, c); await stopGroup(c.child); - const d = await bootStart(coldDir, coldHome, port); + const d = await bootStart(coldDir, coldHome); await grantThenUninstall(d, await authenticate(d), runs.restarted); await stopGroup(d.child); - const e = await bootStart(coldDir, coldHome, port); + const e = await bootStart(coldDir, coldHome); await readAfterRestart(e, runs.restarted); await stopGroup(e.child); @@ -376,14 +373,14 @@ beforeAll(async () => { const reseedDir = join(root, 'reseed'); mkdirSync(reseedDir, { recursive: true }); const reseedHome = join(reseedDir, 'home'); - const f = await bootStart(reseedDir, reseedHome, port); + const f = await bootStart(reseedDir, reseedHome); const fSession = await authenticate(f); runs.reseed.install = await packageInstall(appDir, f); await grantThenUninstall(f, fSession, runs.reseed); runs.reseed.otherInstall = await packageInstall(otherAppDir, f); runs.reseed.afterOtherInstall = await readGrants(f, fSession.token); await stopGroup(f.child); - const g = await bootStart(reseedDir, reseedHome, port); + const g = await bootStart(reseedDir, reseedHome); await readAfterRestart(g, runs.reseed); await stopGroup(g.child); @@ -392,7 +389,7 @@ beforeAll(async () => { // kernel, the install path registers again, in the same process. const reinstallDir = join(root, 'reinstall'); mkdirSync(reinstallDir, { recursive: true }); - const h = await bootStart(reinstallDir, join(reinstallDir, 'home'), port); + const h = await bootStart(reinstallDir, join(reinstallDir, 'home')); const hSession = await authenticate(h); runs.reinstall.install = await packageInstall(appDir, h); await grantThenUninstall(h, hSession, runs.reinstall); diff --git a/packages/cli/test/package-restart-acceptance.integration.test.ts b/packages/cli/test/package-restart-acceptance.integration.test.ts index 8b6c96c7b18..10c444b434f 100644 --- a/packages/cli/test/package-restart-acceptance.integration.test.ts +++ b/packages/cli/test/package-restart-acceptance.integration.test.ts @@ -62,12 +62,11 @@ import { join } from 'node:path'; import { CLI, E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, TSX, childEnv, - portContentionError, - portDriftError, probeThroughChild, - randomPort, + readKernelAssignedPort, } from './helpers/serve-process.js'; /** The banner's tail — every row above it has printed. */ @@ -99,6 +98,8 @@ export default { interface LiveServe { child: ChildProcessWithoutNullStreams; + /** The port the child bound: kernel-assigned, read back off its own banner (#24342). */ + port: number; /** Everything the child has printed so far, both streams. */ output: () => string; } @@ -107,9 +108,9 @@ const children: ChildProcessWithoutNullStreams[] = []; const dirs: string[] = []; /** Boot `os serve --dev` on `db` and keep it running until {@link stop}. */ -function bootServe(dir: string, port: string, db: string): Promise { +function bootServe(dir: string, db: string): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port, '--dev'], { + const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT, '--dev'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header. @@ -126,12 +127,12 @@ function bootServe(dir: string, port: string, db: string): Promise { const what = `os serve --dev on ${db}`; let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, port: verdict, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -140,15 +141,12 @@ function bootServe(dir: string, port: string, db: string): Promise { const onData = (d: unknown) => { out += String(d); // The child is the authority on the port it bound. - if (READY.test(out)) settle(portDriftError(out, what, port)); + if (READY.test(out)) settle(readKernelAssignedPort(out, what)); }; child.stdout.on('data', onData); child.stderr.on('data', onData); child.on('exit', (code) => - settle( - portContentionError(out, what, port) - ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), - ), + settle(new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -300,11 +298,10 @@ describe('#17676 ruling A\' item 5: the three probes across a restart of a stock 'utf8', ); const db = join(dir, 'probe.db'); - const port = randomPort(); - const base = `http://localhost:${port}/api/v1`; // ── boot 1: the card's repro, then the three probes while it serves ────── - const first = await bootServe(dir, port, db); + const first = await bootServe(dir, db); + const base = `http://localhost:${first.port}/api/v1`; const auth = await signIn(first, base); const install = await request(first, 'step 1 — POST /packages', `${base}/packages`, { method: 'POST', @@ -337,11 +334,12 @@ describe('#17676 ruling A\' item 5: the three probes across a restart of a stock before = await threeProbes(first, base, auth.token); await stop(first.child); - // ── boot 2: same database file, same port, then the same three probes ──── - const second = await bootServe(dir, port, db); - const auth2 = await signIn(second, base); + // ── boot 2: same database file, its own read-back port, then the same three probes ──── + const second = await bootServe(dir, db); + const base2 = `http://localhost:${second.port}/api/v1`; + const auth2 = await signIn(second, base2); afterSignIn = auth2.status; - after = await threeProbes(second, base, auth2.token); + after = await threeProbes(second, base2, auth2.token); secondBoot = second.output(); await stop(second.child); }, 420_000); diff --git a/packages/cli/test/released-port-production-spawn.pin.test.ts b/packages/cli/test/released-port-production-spawn.pin.test.ts index 9eb562190d2..a807d4f80d1 100644 --- a/packages/cli/test/released-port-production-spawn.pin.test.ts +++ b/packages/cli/test/released-port-production-spawn.pin.test.ts @@ -74,48 +74,27 @@ const CLI_SPAWNERS: ReadonlySet = new Set(['CLI', 'runServe']); /** The module those draws come from, as an import specifier ends. */ const HARNESS_MODULE = /(?:^|\/)serve-process(?:\.[cm]?[jt]s)?$/; -/** - * Slice 2 of #24342 converts the rest of the development column: the bespoke - * tsx spawners (the seven `package-install-local-*` `os start` files and the - * other files that spawn `CLI` themselves) and the four `bin/run.js --dev` / - * `os dev` files. Until it lands, a lost race there is a drift that each file's - * own read-back reports as a red on a correct tree. - */ -const SLICE_2 = 'slice 2 of #24342'; - /** * Files that spawn the CLI and still draw, each with the EXACT number of draw - * calls it may make and why. Every entry today is a file slice 1 of #24342 did - * not convert; a site that genuinely needs a number BEFORE the spawn and has no - * closed alternative (`KERNEL_ASSIGNED_PORT` read back afterwards, or a port - * held for the whole boot) would earn an entry with that reason instead. + * calls it may make and why. #24342 converted every other spawner; what is left + * is a draw whose non-zero number is the SUBJECT of an arm, or an instrument + * beside the spawn, where `KERNEL_ASSIGNED_PORT` read back afterwards would + * change what the file proves. A new entry needs a reason of that kind, with no + * closed alternative (a port read back afterwards, or one held for the whole boot). */ const RELEASED_DRAW_LEDGER: Readonly> = { - 'artifact-flag-precedence.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'dev-boot-output-order.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-install-local-boot-steps.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-install-local-handlers.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-install-local-hooks.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-install-local-jobs-pull.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-install-local-jobs-shared-name.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-install-local-jobs.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-install-local-uninstall-cleanups.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'package-restart-acceptance.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-app-anchored-optional-import.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-config-boot-flat-docs.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-config-boot-residual.integration.test.ts': { draws: 2, why: SLICE_2 }, - 'serve-host-config-metadata-door.integration.test.ts': { draws: 2, why: SLICE_2 }, - 'serve-mcp-capability-collision.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-mcp-stdio-answers.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-migrate-exit-schema-parity.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-organization-record-page.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-port-drift-notice.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-process-child-env.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-publishes-bound-port.e2e.test.ts': { draws: 2, why: SLICE_2 }, - 'serve-runtime-assets-branding-warning.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-stdio-stdout-purity.e2e.test.ts': { draws: 1, why: SLICE_2 }, - 'serve-view-container-read.integration.test.ts': { draws: 1, why: SLICE_2 }, - 'start-signal-forwarding.e2e.test.ts': { draws: 1, why: SLICE_2 }, + 'serve-port-drift-notice.e2e.test.ts': { + draws: 1, + why: 'the NEGATIVE arm is a boot that binds exactly the non-zero port it asked for and says ' + + 'nothing about ports; `--port 0` asks for no port, and that boot prints plugin-hono-server\'s ' + + '"Port 0 is in use, using port N instead" (#24387)', + }, + 'serve-publishes-bound-port.e2e.test.ts': { + draws: 2, + why: 'reservePort() is the connect instrument\'s NEGATIVE arm, a port probed free and left unbound ' + + '(nothing is spawned on it); randomPort() is the non-zero request the ordinary-boot arm is about, ' + + 'and that arm reads a lost race out of the child\'s own drift notice instead of failing on it', + }, }; interface Census { diff --git a/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts b/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts index 37128244a7f..5cc52742428 100644 --- a/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts +++ b/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts @@ -38,10 +38,9 @@ * to the app, which is the one shape this file must not use. What IS borrowed * from it is everything orthogonal to the directory the child starts in: * `childEnv()` (commit 1ddda1d00) — this file boots the real stack, better-auth included, - * which reads `TEST` directly — plus `randomPort()`, `portContentionError()` - * (#12441) and `portDriftError()` (#12525, wired here by #12548), because a port - * draw is not a property of the CWD either and this file used to carry its own - * second, overlapping one. + * which reads `TEST` directly — plus `KERNEL_ASSIGNED_PORT` and + * `readKernelAssignedPort()` (#24342), because a port is not a property of the + * CWD either and this file used to carry its own second, overlapping draw. * * ── ⚠️ What this file MEASURES: the resolution, and now the BOOT behind it ── * @@ -98,38 +97,17 @@ * is an outcome one test asserts and another test is turned RED by, which is * what makes waiting on them a discriminator rather than a restatement. * - * ── ⚠️ The port apparatus: one LIVE instrument, one insurance ───────────── - * - * #12548 wired the drift read-back into this file as the sibling of - * `serve-process-child-env.e2e.test.ts`, and #12565 recorded HERE that it was - * SILENT on every run this file made. That was true of the tree it was written - * on and is not true of this one, so the note is UPDATED rather than deleted: a - * reader who meets a live instrument described as silent misreads its greens - * exactly as badly as the reverse. - * - * `portDriftError()` ⭐ LIVE since #12567 on the two happy-path runs. - * They print a complete banner, so - * `boundPortFromBanner()` answers `bound` and the port - * this harness asked for is really compared against - * the one the child bound. Still `null` on the - * undeclared leg, which dies before `listen()` — the - * helper's own stated contract, not a gap. - * - * `portContentionError()` STILL INSURANCE — and ⛔ not for the reason the old - * note gave. Its silence never depended on the banner: - * these children spawn through `bin/run-dev.js`, which - * pins `NODE_ENV=development` before argv is parsed, - * so `serve`'s auto-shift branch is open and a taken - * port is a hop to the next free one, never the bind - * failure that helper reads for. That is a property of - * the SPAWN POSTURE, so booting to a banner cannot - * make it fire. It stays because the helper is the - * shared shape and a spawn posture can change; - * ⛔ do not read its green as a look. - * - * ⇒ this file's exposure is still narrower than the security file's — it issues - * no HTTP request — but its happy-path children now DO bind a port there is - * something to drift off, which is the half that used to be nil. + * ── The port apparatus: a kernel-assigned port, read back ──────────────── + * + * Every child is spawned on `KERNEL_ASSIGNED_PORT` (#24342): its own `listen()` + * picks the port, so no number this harness drew can be taken in a gap and + * there is nothing to contend for or drift from — which retired this file's + * `portContentionError()` and `portDriftError()` checks. What replaces them is + * the read-back: the two happy-path runs print a complete banner, and + * `readKernelAssignedPort()` must find the non-zero port the child bound in it + * or the run rejects. The undeclared leg dies before `listen()` and prints no + * banner, so it gets no port verdict — the helper's own stated contract, not a + * gap. This file issues no HTTP request, so the port is checked, never used. * * ── The anti-vacuity floor ─────────────────────────────────────────────── * @@ -169,7 +147,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; -import { childEnv, portContentionError, portDriftError, randomPort } from './helpers/serve-process.js'; +import { boundPortFromBanner, childEnv, KERNEL_ASSIGNED_PORT, readKernelAssignedPort } from './helpers/serve-process.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -379,16 +357,9 @@ interface Run { stdout: string; stderr: string; both: string; outcome: RunOutcom * An early exit resolves rather than rejects: a boot that DIES still has to have * said why, and the refusal case below reads exactly that. * - * ⭐ TWO exceptions, both about the port and both about the same mis-signalling. - * A boot that DIED because it could not BIND rejects, naming the port (#12441): - * resolving it would hand the caller an output buffer with no marker in it, and - * the assertions would then fail with "the cluster gate was not loaded from the - * app" — a sentence about resolution bases, for a failure that is entirely about - * a port. That is the whole cost the card measured, and it is more expensive - * than the lost run. A boot that SUCCEEDED on a port other than the one it was - * asked for rejects too (#12525, wired by #12548) — ⚠️ silent on this tree for - * the measured reason in this file's header, and kept for the tree where it is - * not. + * ⭐ ONE exception, about the port: a run whose complete banner names no + * non-zero port it bound rejects with `readKernelAssignedPort()`'s message + * rather than resolving a boot this harness cannot say it read (#24342). */ function runServeFrom( cwd: string, @@ -397,15 +368,9 @@ function runServeFrom( timeoutMs = 240_000, ): Promise { return new Promise((resolveRun, rejectRun) => { - // ⛔ Was an inline `String(40000 + Math.random() * 20000)`, a SECOND blind - // draw whose range overlapped the one in - // `serve-node-env-production-default.e2e.test.ts` (41000-60000) — so under - // `--maxWorkers > 1` the two files could collide with EACH OTHER, not only - // with a neighbouring agent's dev server. One bind-probed draw now, in the - // helper; its docblock is the authority on what that does and does not - // guarantee. - const port = randomPort(); - const child = spawn(TSX, [CLI, 'serve', configArg, '--port', port], { + // A kernel-assigned port (#24342): no number is drawn here, so none can be + // taken between a pick and the child's bind. + const child = spawn(TSX, [CLI, 'serve', configArg, '--port', KERNEL_ASSIGNED_PORT], { cwd, env: childEnv({ NO_COLOR: '1', @@ -426,19 +391,12 @@ function runServeFrom( settled = true; clearTimeout(timer); try { child.kill('SIGTERM'); } catch { /* already gone */ } - const contended = portContentionError(stdout + stderr, WHAT, port); - if (contended) { - rejectRun(contended); - return; - } - // ⭐ The read-back half (#12548), LIVE here since #12567: the happy-path - // children print a complete banner, so this really compares the port the - // harness asked for against the one the child bound. ⛔ Still not a look - // on the undeclared leg, which dies before `listen()` — this file's - // header carries both halves of that measurement. - const drifted = portDriftError(stdout + stderr, WHAT, port); - if (drifted) { - rejectRun(drifted); + // ⭐ The read-back, off a COMPLETE banner only: the undeclared leg dies + // before `listen()` and announces no port (this file's header). + const both = stdout + stderr; + const bound = boundPortFromBanner(both).state === 'no-banner' ? null : readKernelAssignedPort(both, WHAT); + if (bound instanceof Error) { + rejectRun(bound); return; } resolveRun({ stdout, stderr, both: stdout + stderr, outcome }); diff --git a/packages/cli/test/serve-config-boot-flat-docs.integration.test.ts b/packages/cli/test/serve-config-boot-flat-docs.integration.test.ts index b0c01b094a8..6ead0686195 100644 --- a/packages/cli/test/serve-config-boot-flat-docs.integration.test.ts +++ b/packages/cli/test/serve-config-boot-flat-docs.integration.test.ts @@ -57,12 +57,11 @@ import { join } from 'node:path'; import { CLI, E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, TSX, childEnv, - portContentionError, - portDriftError, probeThroughChild, - randomPort, + readKernelAssignedPort, } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; @@ -138,13 +137,15 @@ function project(prefix: string, config: string, serviceDocs: boolean): string { interface LiveServe { child: ChildProcessWithoutNullStreams; + /** The port the child bound: kernel-assigned, read back off its own banner (#24342). */ + port: number; output: () => string; } /** Boot `os serve objectstack.config.ts --dev` and keep it running; `--dev` seeds the admin. */ -function bootServe(dir: string, port: string): Promise { +function bootServe(dir: string): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port, '--dev'], { + const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT, '--dev'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). @@ -161,12 +162,12 @@ function bootServe(dir: string, port: string): Promise { const what = `os serve --dev in ${dir}`; let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, port: verdict, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -175,15 +176,12 @@ function bootServe(dir: string, port: string): Promise { const onData = (d: unknown) => { out += String(d); // The child is the authority on the port it bound (#12525). - if (READY.test(out)) settle(portDriftError(out, what, port)); + if (READY.test(out)) settle(readKernelAssignedPort(out, what)); }; child.stdout.on('data', onData); child.stderr.on('data', onData); child.on('exit', (code) => - settle( - portContentionError(out, what, port) - ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), - ), + settle(new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -243,9 +241,8 @@ interface Reading { /** Boot `dir`, sign in as the seeded admin, read `GET /api/v1/meta/doc` and every doc it lists, stop. */ async function readDocs(dir: string): Promise { - const port = randomPort(); - const base = `http://localhost:${port}/api/v1`; - const serve = await bootServe(dir, port); + const serve = await bootServe(dir); + const base = `http://localhost:${serve.port}/api/v1`; try { const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, { method: 'POST', diff --git a/packages/cli/test/serve-config-boot-residual.integration.test.ts b/packages/cli/test/serve-config-boot-residual.integration.test.ts index c8eeb3afb32..a5cd78f1173 100644 --- a/packages/cli/test/serve-config-boot-residual.integration.test.ts +++ b/packages/cli/test/serve-config-boot-residual.integration.test.ts @@ -61,12 +61,11 @@ import { join } from 'node:path'; import { CLI, E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, TSX, childEnv, - portContentionError, - portDriftError, probeThroughChild, - randomPort, + readKernelAssignedPort, } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; import { viewContainerNameRefusal } from '@objectstack/objectql'; @@ -228,13 +227,15 @@ async function buildArtifact(dir: string): Promise { interface LiveServe { child: ChildProcessWithoutNullStreams; + /** The port the child bound: kernel-assigned, read back off its own banner (#24342). */ + port: number; output: () => string; } /** Boot `os serve` in `dir` with `args`; the source entry seeds the dev admin either way. */ -function bootServe(dir: string, args: string[], port: string): Promise { +function bootServe(dir: string, args: string[]): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'serve', ...args, '-p', port], { + const child = spawn(TSX, [CLI, 'serve', ...args, '-p', KERNEL_ASSIGNED_PORT], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). @@ -251,12 +252,12 @@ function bootServe(dir: string, args: string[], port: string): Promise { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, port: verdict, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -265,15 +266,12 @@ function bootServe(dir: string, args: string[], port: string): Promise { out += String(d); // The child is the authority on the port it bound (#12525). - if (READY.test(out)) settle(portDriftError(out, what, port)); + if (READY.test(out)) settle(readKernelAssignedPort(out, what)); }; child.stdout.on('data', onData); child.stderr.on('data', onData); child.on('exit', (code) => - settle( - portContentionError(out, what, port) - ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), - ), + settle(new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -336,9 +334,8 @@ interface Reading { /** Boot, sign in as the seeded admin, read the three listings and every listed doc, stop. */ async function read(dir: string, args: string[]): Promise { - const port = randomPort(); - const base = `http://localhost:${port}/api/v1`; - const serve = await bootServe(dir, args, port); + const serve = await bootServe(dir, args); + const base = `http://localhost:${serve.port}/api/v1`; try { const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, { method: 'POST', @@ -397,8 +394,7 @@ async function read(dir: string, args: string[]): Promise { */ function bootRefused(dir: string, args: string[]): Promise<{ code: number | null; output: string }> { return new Promise((resolveRefusal, rejectRefusal) => { - const port = randomPort(); - const child = spawn(TSX, [CLI, 'serve', ...args, '-p', port], { + const child = spawn(TSX, [CLI, 'serve', ...args, '-p', KERNEL_ASSIGNED_PORT], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). diff --git a/packages/cli/test/serve-host-config-metadata-door.integration.test.ts b/packages/cli/test/serve-host-config-metadata-door.integration.test.ts index 658e7c05e6f..a94ecbe521b 100644 --- a/packages/cli/test/serve-host-config-metadata-door.integration.test.ts +++ b/packages/cli/test/serve-host-config-metadata-door.integration.test.ts @@ -57,10 +57,9 @@ import { CLI, childEnv, E2E_SECRET_KEY, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, TSX, } from './helpers/serve-process.js'; import { defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; @@ -117,9 +116,9 @@ const groups: ChildProcess[] = []; let dir: string; const readings: Partial> = {}; -function boot(argv: string[], port: string, env: Record): Promise { +function boot(argv: string[], env: Record): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, ...argv, '-p', port], { + const child = spawn(TSX, [CLI, ...argv, '-p', KERNEL_ASSIGNED_PORT], { cwd: dir, // `childEnv`, never a bare `...process.env` — see its header. Neither // artifact variable leaks in from the runner: each leg states its own. @@ -140,12 +139,12 @@ function boot(argv: string[], port: string, env: Record { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`os ${argv[0]} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -153,13 +152,13 @@ function boot(argv: string[], port: string, env: Record { out += String(d); - if (READY.test(out)) settle(portDriftError(out, `os ${argv[0]}`, port)); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, `os ${argv[0]}`)); }; child.stdout?.on('data', onData); child.stderr?.on('data', onData); child.on('exit', (code) => - settle(portContentionError(out, `os ${argv[0]}`, port) - ?? new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -256,14 +255,12 @@ beforeAll(async () => { writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSourceFromLiteral(literal)); linkSpec(dir); - let port = randomPort(); - await measure('source', boot(['serve', 'objectstack.config.ts'], port, { OS_DATABASE_URL: 'file:source.db' })); + await measure('source', boot(['serve', 'objectstack.config.ts'], { OS_DATABASE_URL: 'file:source.db' })); // The compiled twin, for the artifact door's control leg. mkdirSync(join(dir, 'dist'), { recursive: true }); writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(STACK, null, 2), 'utf8'); - port = randomPort(); - await measure('artifact', boot(['serve', 'objectstack.config.ts'], port, { OS_DATABASE_URL: 'file:artifact.db' })); + await measure('artifact', boot(['serve', 'objectstack.config.ts'], { OS_DATABASE_URL: 'file:artifact.db' })); }, ALL_BOOTS_TIMEOUT_MS); afterAll(async () => { diff --git a/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts b/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts index 3314b060ed6..38c478a8ac7 100644 --- a/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts +++ b/packages/cli/test/serve-mcp-capability-collision.e2e.test.ts @@ -50,10 +50,10 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, RUN_JS_RESOLVES_FROM_DIST, childEnv, probeThroughChild, - randomPort, requireBuiltCli, } from './helpers/serve-process.js'; @@ -153,9 +153,9 @@ let bootStdout = ''; let serveChild: ChildProcessWithoutNullStreams | undefined; const children: ChildProcessWithoutNullStreams[] = []; -function boot(env: Record, waitFor: RegExp): Promise { +function boot(env: Record, waitFor: RegExp): Promise<{ child: ChildProcessWithoutNullStreams; port: string }> { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(process.execPath, [CLI, 'serve', '-p', port, '--dev'], { + const child = spawn(process.execPath, [CLI, 'serve', '-p', KERNEL_ASSIGNED_PORT, '--dev'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, not a bare `...process.env`: the vitest worker exports @@ -219,30 +219,31 @@ function boot(env: Record, waitFor: RegExp): Promise const timer = setTimeout(() => { if (settled) return; settled = true; - rejectBoot(new Error(`serve never printed ${waitFor} (child-reported bound port: ${boundPort(out + err) ?? 'NEVER PRINTED'}; this file reserved ${port})\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`)); + rejectBoot(new Error(`serve never printed ${waitFor} (child-reported bound port: ${boundPort(out + err) ?? 'NEVER PRINTED'})\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`)); }, 150_000); const onOutput = () => { bootOutput = err; bootStdout = out; if (settled) return; - // ⭐ #12526: the child is the authority on which port it bound, so read it - // back before handing this boot to assertions that will use `port`. + // ⭐ #12526: the child is the authority on which port it bound — a + // kernel-assigned one (#24342) — so read it back before handing this boot + // to assertions that will use it. Port 0 is the REQUEST announced back. const bound = boundPort(out + err); - if (bound !== null && bound !== port) { + if (bound === '0') { settled = true; clearTimeout(timer); - rejectBoot(portDriftError(port, bound, out, err)); + rejectBoot(new Error(`serve announced port 0, the request rather than the port it bound\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`)); return; } // `bound !== null` is part of the gate, not an optimisation: resolving on // `waitFor` alone would let a boot through before the child had said which - // port it took, and the drift check would then be a no-op on an already - // settled promise. Every marker below arrives with or before the banner. + // port it took, so no request could be addressed to it. Every marker + // below arrives with or before the banner. if (bound !== null && waitFor.test(out + err)) { settled = true; clearTimeout(timer); - resolveBoot(child); + resolveBoot({ child, port: bound }); } }; @@ -264,30 +265,14 @@ function boot(env: Record, waitFor: RegExp): Promise } /** - * The port the CHILD says it bound — read out of the child's OWN output, never - * out of what this file reserved (#12526, and #12441 ruling 2 before it). + * The port the CHILD says it bound — read out of the child's OWN output (#12526). * - * ## Why this file needs it at all - * - * The spawn below passes `--dev`, and `serve.ts` reads - * `portAutoShiftAllowed = flags.dev || NODE_ENV === 'development'` — `flags.dev` - * ALONE opens the auto-select branch, whatever `NODE_ENV` is. So a port taken - * between `randomPort()`'s bind probe and this spawn does NOT fail the boot the - * way it does in `serve-node-env-production-default.e2e.test.ts` (no `--dev` - * there, so unset `NODE_ENV` defaults to production and a taken port is a hard - * `exit 1` that `portContentionError()` can name). Here `getAvailablePort()` - * silently hops the child onto the next free port and it reports itself READY. - * - * That is the strictly worse direction: a GREEN boot on the wrong port. Every - * request this file makes afterwards goes to `port` — the port it reserved and - * no longer owns — so it measures whatever else took it. Measured on this tree - * with a neighbour holding the reserved port: reserved 34259, child bound - * 34260, boot green, and the file's own next request was answered - * `{"iAm":"A NEIGHBOURING AGENT DEV SERVER, not os serve"}`. - * - * ⚠️ The `--dev` responsible has been on this spawn line since `83e6016fa` — it - * long predates #11707/#12459, which changed `NODE_ENV` and never touched it. - * This was never read, not newly introduced. + * The spawn asks for `KERNEL_ASSIGNED_PORT` (#24342), so this is the only way + * this file learns its port: nothing is drawn before the spawn, so nothing can + * be taken between a pick and the child's bind. That race used to cost more + * here than anywhere: `--dev` alone opens `serve`'s auto-shift branch, so a + * lost race was a GREEN boot on another port, and this file's requests then + * reached whatever held the one it had drawn. * * Two patterns because either one alone can be absent: the structured log obeys * `OS_LOG_LEVEL`, and the banner line is what survives when it does not. @@ -298,28 +283,6 @@ function boundPort(output: string): string | null { return match ? match[1] : null; } -/** - * A lost port race, said out loud — the failure this file used to hide behind a - * green boot (#12526). - */ -function portDriftError(reserved: string, bound: string, out: string, err: string): Error { - return new Error( - `PORT DRIFT: this file reserved port ${reserved}, but the child bound ${bound}.\n` - + `\`os serve -p ${reserved} --dev\` passes \`--dev\`, so \`serve.ts\`'s ` - + `\`portAutoShiftAllowed = flags.dev || NODE_ENV === 'development'\` opened the auto-select ` - + `branch and \`getAvailablePort()\` hopped the child off the port that was asked for. The ` - + `boot SUCCEEDED — on the wrong port.\n` - + `⛔ Something else took ${reserved} between \`randomPort()\`'s bind probe and the spawn. That ` - + `is a HOST race (several agents share one container), not a verdict about the code under ` - + `test — but it is NOT harmless here: every assertion below talks to ${reserved}, which is ` - + `now that other process, so continuing would measure a stranger rather than this boot.\n` - + `⛔ Do not "fix" this by following the child to ${bound}: the point is that the port this ` - + `file uses and the port the child bound must be the SAME port. Re-run this file in ` - + `isolation; if it reproduces there, the port is genuinely held.\n` - + `--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`, - ); -} - async function stop(child: ChildProcessWithoutNullStreams): Promise { if (child.exitCode !== null || child.signalCode !== null) return; await new Promise((done) => { @@ -433,11 +396,9 @@ describe('#7652: an app loading the MCP client connector still gets the MCP serv JSON.stringify({ name: 'mcp-collision-e2e-fixture', private: true, type: 'module' }, null, 2), 'utf8', ); - port = randomPort(); + ({ child: serveChild, port } = await boot({ OS_DATABASE_URL: join(dir, 'probe.db') }, /Server is ready/)); base = `http://localhost:${port}/api/v1`; - serveChild = await boot({ OS_DATABASE_URL: join(dir, 'probe.db') }, /Server is ready/); - const signIn = await probe('the sign-in probe', async () => { const res = await fetch(`${base}/auth/sign-in/email`, { method: 'POST', diff --git a/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts b/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts index ad31cd24cf6..3551d69d5c2 100644 --- a/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts +++ b/packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts @@ -65,10 +65,10 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, RUN_JS_RESOLVES_FROM_DIST, childEnv, probeThroughChild, - randomPort, requireBuiltCli, } from './helpers/serve-process.js'; @@ -152,13 +152,14 @@ export default { `; let dir: string; -let port: string; let apiKey: string; /** Every child this file spawns, so a failed assertion never leaks a server. */ const children: ChildProcessWithoutNullStreams[] = []; interface Booted { child: ChildProcessWithoutNullStreams; + /** The port the child bound, read back off its own output. */ + port: string; stdout: () => string; stderr: () => string; } @@ -186,7 +187,7 @@ function boot( nodeArgs: readonly string[] = [], ): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(process.execPath, [...nodeArgs, CLI, 'serve', '-p', port, '--dev'], { + const child = spawn(process.execPath, [...nodeArgs, CLI, 'serve', '-p', KERNEL_ASSIGNED_PORT, '--dev'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, not a bare `...process.env`: the vitest worker exports @@ -251,29 +252,30 @@ function boot( if (settled) return; settled = true; rejectBoot( - new Error(`serve never printed ${waitFor} (child-reported bound port: ${boundPort(out + err) ?? 'NEVER PRINTED'}; this file reserved ${port})\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`), + new Error(`serve never printed ${waitFor} (child-reported bound port: ${boundPort(out + err) ?? 'NEVER PRINTED'})\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`), ); }, 150_000); const onOutput = () => { if (settled) return; - // ⭐ #12526: the child is the authority on which port it bound, so read it - // back before handing this boot to assertions that will use `port`. + // ⭐ #12526: the child is the authority on which port it bound — a + // kernel-assigned one (#24342) — so read it back before handing this boot + // to assertions that will use it. Port 0 is the REQUEST announced back. const bound = boundPort(out + err); - if (bound !== null && bound !== port) { + if (bound === '0') { settled = true; clearTimeout(timer); - rejectBoot(portDriftError(port, bound, out, err)); + rejectBoot(new Error(`serve announced port 0, the request rather than the port it bound\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`)); return; } // `bound !== null` is part of the gate, not an optimisation: resolving on // `waitFor` alone would let a boot through before the child had said which - // port it took, and the drift check would then be a no-op on an already - // settled promise. Every marker below arrives with or before the banner. + // port it took, so no request could be addressed to it. Every marker + // below arrives with or before the banner. if (bound !== null && waitFor.test(out + err)) { settled = true; clearTimeout(timer); - resolveBoot({ child, stdout: () => out, stderr: () => err }); + resolveBoot({ child, port: bound, stdout: () => out, stderr: () => err }); } }; @@ -297,30 +299,14 @@ function boot( } /** - * The port the CHILD says it bound — read out of the child's OWN output, never - * out of what this file reserved (#12526, and #12441 ruling 2 before it). + * The port the CHILD says it bound — read out of the child's OWN output (#12526). * - * ## Why this file needs it at all - * - * The spawn below passes `--dev`, and `serve.ts` reads - * `portAutoShiftAllowed = flags.dev || NODE_ENV === 'development'` — `flags.dev` - * ALONE opens the auto-select branch, whatever `NODE_ENV` is. So a port taken - * between `randomPort()`'s bind probe and this spawn does NOT fail the boot the - * way it does in `serve-node-env-production-default.e2e.test.ts` (no `--dev` - * there, so unset `NODE_ENV` defaults to production and a taken port is a hard - * `exit 1` that `portContentionError()` can name). Here `getAvailablePort()` - * silently hops the child onto the next free port and it reports itself READY. - * - * That is the strictly worse direction: a GREEN boot on the wrong port. Every - * request this file makes afterwards goes to `port` — the port it reserved and - * no longer owns — so it measures whatever else took it. Measured on this tree - * with a neighbour holding the reserved port: reserved 34259, child bound - * 34260, boot green, and the file's own next request was answered - * `{"iAm":"A NEIGHBOURING AGENT DEV SERVER, not os serve"}`. - * - * ⚠️ The `--dev` responsible has been on this spawn line since `83e6016fa` — it - * long predates #11707/#12459, which changed `NODE_ENV` and never touched it. - * This was never read, not newly introduced. + * The spawn asks for `KERNEL_ASSIGNED_PORT` (#24342), so this is the only way + * this file learns its port: nothing is drawn before the spawn, so nothing can + * be taken between a pick and the child's bind. That race used to cost more + * here than anywhere: `--dev` alone opens `serve`'s auto-shift branch, so a + * lost race was a GREEN boot on another port, and this file's requests then + * reached whatever held the one it had drawn. * * Two patterns because either one alone can be absent: the structured log obeys * `OS_LOG_LEVEL`, and the banner line is what survives when it does not. @@ -331,28 +317,6 @@ function boundPort(output: string): string | null { return match ? match[1] : null; } -/** - * A lost port race, said out loud — the failure this file used to hide behind a - * green boot (#12526). - */ -function portDriftError(reserved: string, bound: string, out: string, err: string): Error { - return new Error( - `PORT DRIFT: this file reserved port ${reserved}, but the child bound ${bound}.\n` - + `\`os serve -p ${reserved} --dev\` passes \`--dev\`, so \`serve.ts\`'s ` - + `\`portAutoShiftAllowed = flags.dev || NODE_ENV === 'development'\` opened the auto-select ` - + `branch and \`getAvailablePort()\` hopped the child off the port that was asked for. The ` - + `boot SUCCEEDED — on the wrong port.\n` - + `⛔ Something else took ${reserved} between \`randomPort()\`'s bind probe and the spawn. That ` - + `is a HOST race (several agents share one container), not a verdict about the code under ` - + `test — but it is NOT harmless here: every assertion below talks to ${reserved}, which is ` - + `now that other process, so continuing would measure a stranger rather than this boot.\n` - + `⛔ Do not "fix" this by following the child to ${bound}: the point is that the port this ` - + `file uses and the port the child bound must be the SAME port. Re-run this file in ` - + `isolation; if it reproduces there, the port is genuinely held.\n` - + `--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`, - ); -} - async function stop(child: ChildProcessWithoutNullStreams): Promise { if (child.exitCode !== null || child.signalCode !== null) return; await new Promise((done) => { @@ -389,12 +353,11 @@ describe('#7645: the stdio MCP transport answers over a spawned CLI process', () JSON.stringify({ name: 'mcp-stdio-e2e-fixture', private: true, type: 'module' }, null, 2), 'utf8', ); - port = randomPort(); // Boot 1 — mint a real key through the product route, against a FILE db so // boot 2 sees the same row (`:memory:` would not survive the restart). const first = await boot({ OS_DATABASE_URL: join(dir, 'probe.db') }, /Server is ready/); - const base = `http://localhost:${port}/api/v1`; + const base = `http://localhost:${first.port}/api/v1`; // ⭐ #15653. `boot()`'s `child.on('exit')` handler feeds the READINESS promise // ONLY — it returns early once `settled` — so a death AFTER readiness is @@ -414,7 +377,7 @@ describe('#7645: the stdio MCP transport answers over a spawned CLI process', () child: first.child, transcript, label: 'serve-mcp-stdio-answers', - what: `${what} on port ${port}`, + what: `${what} on port ${first.port}`, }, request, ); diff --git a/packages/cli/test/serve-migrate-exit-schema-parity.integration.test.ts b/packages/cli/test/serve-migrate-exit-schema-parity.integration.test.ts index a5bd577992e..9d7ef446bad 100644 --- a/packages/cli/test/serve-migrate-exit-schema-parity.integration.test.ts +++ b/packages/cli/test/serve-migrate-exit-schema-parity.integration.test.ts @@ -49,7 +49,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; -import { CLI, E2E_SECRET_KEY, TSX, childEnv, randomPort } from './helpers/serve-process.js'; +import { CLI, E2E_SECRET_KEY, KERNEL_ASSIGNED_PORT, TSX, childEnv } from './helpers/serve-process.js'; /** The fixture app's own object; its table proves the config was loaded. */ const FIXTURE_OBJECT = 'parity_task'; @@ -93,7 +93,7 @@ function migrateAndExit(label: string, extraArgs: string[]): Promise return new Promise((resolveRun, rejectRun) => { const child = spawn( TSX, - [CLI, 'serve', 'objectstack.config.ts', '--port', randomPort(), ...extraArgs], + [CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT, ...extraArgs], { cwd: fixtureDir, env: childEnv({ diff --git a/packages/cli/test/serve-organization-record-page.e2e.test.ts b/packages/cli/test/serve-organization-record-page.e2e.test.ts index 11154ce6b01..8b8b56d568b 100644 --- a/packages/cli/test/serve-organization-record-page.e2e.test.ts +++ b/packages/cli/test/serve-organization-record-page.e2e.test.ts @@ -39,12 +39,11 @@ import { join } from 'node:path'; import { CLI, E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, TSX, childEnv, - portContentionError, - portDriftError, probeThroughChild, - randomPort, + readKernelAssignedPort, } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; @@ -74,12 +73,14 @@ function project(withOrganizationPage: boolean): string { interface LiveServe { child: ChildProcessWithoutNullStreams; + /** The port the child bound: kernel-assigned, read back off its own banner (#24342). */ + port: number; output: () => string; } -function bootServe(dir: string, port: string): Promise { +function bootServe(dir: string): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port], { + const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). @@ -95,12 +96,12 @@ function bootServe(dir: string, port: string): Promise { const what = `os serve in ${dir}`; let out = ''; let settled = false; - const settle = (err: Error | null) => { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, port: verdict, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -109,15 +110,12 @@ function bootServe(dir: string, port: string): Promise { const onData = (d: unknown) => { out += String(d); // The child is the authority on the port it bound (#12525). - if (READY.test(out)) settle(portDriftError(out, what, port)); + if (READY.test(out)) settle(readKernelAssignedPort(out, what)); }; child.stdout.on('data', onData); child.stderr.on('data', onData); child.on('exit', (code) => - settle( - portContentionError(out, what, port) - ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), - ), + settle(new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -169,9 +167,8 @@ async function request(serve: LiveServe, what: string, url: string, init: Reques type Defaults = Record; async function servedDefaults(withOrganizationPage: boolean): Promise<{ defaults: Defaults; output: string }> { - const port = randomPort(); - const base = `http://localhost:${port}/api/v1`; - const serve = await bootServe(project(withOrganizationPage), port); + const serve = await bootServe(project(withOrganizationPage)); + const base = `http://localhost:${serve.port}/api/v1`; try { const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, { method: 'POST', diff --git a/packages/cli/test/serve-process-child-env.e2e.test.ts b/packages/cli/test/serve-process-child-env.e2e.test.ts index 4b7a852f672..e8a6d7e86b6 100644 --- a/packages/cli/test/serve-process-child-env.e2e.test.ts +++ b/packages/cli/test/serve-process-child-env.e2e.test.ts @@ -85,6 +85,8 @@ * directory where being wrong matters most. So `readyVerdict()` below settles * only once the child has SAID which port it took, and `portDriftError()` * (`helpers/serve-process.ts`, #12525) REFUSES a mismatch rather than probing. + * The origin legs ask for a kernel-assigned port (#24342), so no number is + * drawn for them at all; only the forced-drift arm hands over one, held. * * ⛔ The gate keys on the banner's TAIL, never on `Server is ready`. That line * is the banner's HEAD, printed one `console.error` before the `API:` row that @@ -118,9 +120,9 @@ import { boundPortFromBanner, childEnv, holdPort, + KERNEL_ASSIGNED_PORT, portDriftError, probeThroughChild, - randomPort, VITEST_WORKER_ENV_KEYS, } from './helpers/serve-process.js'; @@ -201,7 +203,7 @@ const WHAT = 'os serve (bin/run-dev.js \u21d2 NODE_ENV=development, spawned dire type ReadyVerdict = /** The child has not announced a bound port yet — keep waiting. */ | { settled: false } - /** It announced the port it was asked for, and this is that number. */ + /** It announced the port it bound: the one it was asked for, or a kernel-assigned one. */ | { settled: true; port: number } /** It announced a DIFFERENT port, or a banner that cannot say which. */ | { settled: true; refusal: Error }; @@ -258,11 +260,11 @@ function readyVerdict(output: string, requestedPort: string): ReadyVerdict { * * `requestedPort` is a parameter rather than a local so the forced-drift arm * below can hand it a port that is genuinely HELD. ⛔ Nothing else should pass - * one: the default draw is bind-probed, and a hand-picked number is not. + * one: the default is a kernel-assigned port (#24342), and any number is a draw. */ async function probeOrigin( env: Record, - requestedPort: string = randomPort(), + requestedPort: string = KERNEL_ASSIGNED_PORT, ): Promise<{ status: number; code: unknown }> { const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '--port', requestedPort], { cwd: dir, @@ -311,10 +313,10 @@ async function probeOrigin( // `probeThroughChild()` is where that is now read; its own section in // `helpers/serve-process.ts` carries the measurement and the fences. // - // ⭐ `bound`, not `requestedPort`. They are provably the same number on this - // line — a mismatch was REFUSED above rather than reaching it — and - // addressing the child's own announced port is what keeps that true if the - // gate is ever loosened. + // ⭐ `bound`, not `requestedPort`. For a kernel-assigned request they differ + // by design; for an explicit port a mismatch was REFUSED above rather than + // reaching this line, and addressing the child's own announced port is what + // keeps that true if the gate is ever loosened. return await probeThroughChild( { child, diff --git a/packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts b/packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts index 84cbee35514..5622a183d14 100644 --- a/packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts +++ b/packages/cli/test/serve-runtime-assets-branding-warning.e2e.test.ts @@ -48,11 +48,10 @@ import { CLI, TSX, E2E_SECRET_KEY, - boundPortFromBanner, + KERNEL_ASSIGNED_PORT, childEnv, - portContentionError, probeThroughChild, - reservePort, + readKernelAssignedPort, } from './helpers/serve-process.js'; type ServeChild = ChildProcessByStdio; @@ -149,8 +148,7 @@ async function bootAndFetchLogo(layout: 'no-assets' | 'cwd-assets' | 'env-assets // only for the `assets/` control, that directory — nothing else. expect(readdirSync(cwd).sort()).toEqual(layout === 'cwd-assets' ? ['assets', 'objectstack.json'] : ['objectstack.json']); - const port = reservePort(); - const child = spawn(TSX, [CLI, 'serve', '-p', String(port)], { + const child = spawn(TSX, [CLI, 'serve', '-p', KERNEL_ASSIGNED_PORT], { cwd, stdio: ['ignore', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` (`check:cli-test-child-env`). @@ -186,19 +184,15 @@ async function bootAndFetchLogo(layout: 'no-assets' | 'cwd-assets' | 'env-assets child.stderr.on('data', onData); child.on('exit', (code) => { clearTimeout(timer); - failed( - portContentionError(output, 'os serve (bin/run-dev.js, artifact boot)', port) - ?? new Error(`serve exited ${code} before its ready banner\n--- output ---\n${output}`), - ); + failed(new Error(`serve exited ${code} before its ready banner\n--- output ---\n${output}`)); }); }); - // `bin/run-dev.js` pins NODE_ENV=development, where a taken port is a hop, - // not an error — so the port is read back off the child's own banner. - const readback = boundPortFromBanner(output); - if (readback.state !== 'bound') { + // The child took a kernel-assigned port (#24342), so it is read back off its own banner. + const port = readKernelAssignedPort(output, 'os serve (bin/run-dev.js, artifact boot)'); + if (port instanceof Error) { await stop(child); - throw new Error(`cannot read the bound port back (${readback.state})\n--- output ---\n${output}`); + throw port; } try { @@ -207,10 +201,10 @@ async function bootAndFetchLogo(layout: 'no-assets' | 'cwd-assets' | 'env-assets child, transcript: () => `\n--- child output ---\n${output}`, label: 'serve-runtime-assets-branding-warning', - what: `GET ${LOGO_URL} on port ${readback.port}`, + what: `GET ${LOGO_URL} on port ${port}`, }, async () => { - const res = await fetch(`http://localhost:${readback.port}${LOGO_URL}`); + const res = await fetch(`http://localhost:${port}${LOGO_URL}`); return { status: res.status, contentType: res.headers.get('content-type'), body: await res.text() }; }, ); diff --git a/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts b/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts index 68af611e933..2ad84f78276 100644 --- a/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts +++ b/packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts @@ -46,10 +46,10 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, RUN_JS_RESOLVES_FROM_DIST, childEnv, probeThroughChild, - randomPort, requireBuiltCli, } from './helpers/serve-process.js'; @@ -109,12 +109,13 @@ export default { `; let dir: string; -let port: string; let apiKey: string; const children: ChildProcessWithoutNullStreams[] = []; interface Booted { child: ChildProcessWithoutNullStreams; + /** The port the child bound, read back off its own output. */ + port: string; stdout: () => string; stderr: () => string; } @@ -133,7 +134,7 @@ interface Booted { */ function boot(env: Record, waitFor: RegExp): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(process.execPath, [CLI, 'serve', '-p', port, '--dev'], { + const child = spawn(process.execPath, [CLI, 'serve', '-p', KERNEL_ASSIGNED_PORT, '--dev'], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, not a bare `...process.env`: the vitest worker exports @@ -202,30 +203,31 @@ function boot(env: Record, waitFor: RegExp): Promise settled = true; rejectBoot( new Error( - `serve never printed ${waitFor} (child-reported bound port: ${boundPort(out + err) ?? 'NEVER PRINTED'}; this file reserved ${port})\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`, + `serve never printed ${waitFor} (child-reported bound port: ${boundPort(out + err) ?? 'NEVER PRINTED'})\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`, ), ); }, 150_000); const onOutput = () => { if (settled) return; - // ⭐ #12526: the child is the authority on which port it bound, so read it - // back before handing this boot to assertions that will use `port`. + // ⭐ #12526: the child is the authority on which port it bound — a + // kernel-assigned one (#24342) — so read it back before handing this boot + // to assertions that will use it. Port 0 is the REQUEST announced back. const bound = boundPort(out + err); - if (bound !== null && bound !== port) { + if (bound === '0') { settled = true; clearTimeout(timer); - rejectBoot(portDriftError(port, bound, out, err)); + rejectBoot(new Error(`serve announced port 0, the request rather than the port it bound\n--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`)); return; } // `bound !== null` is part of the gate, not an optimisation: resolving on // `waitFor` alone would let a boot through before the child had said which - // port it took, and the drift check would then be a no-op on an already - // settled promise. Every marker below arrives with or before the banner. + // port it took, so no request could be addressed to it. Every marker + // below arrives with or before the banner. if (bound === null || !waitFor.test(out + err)) return; settled = true; clearTimeout(timer); - resolveBoot({ child, stdout: () => out, stderr: () => err }); + resolveBoot({ child, port: bound, stdout: () => out, stderr: () => err }); }; child.stdout.on('data', (d) => { @@ -250,30 +252,14 @@ function boot(env: Record, waitFor: RegExp): Promise } /** - * The port the CHILD says it bound — read out of the child's OWN output, never - * out of what this file reserved (#12526, and #12441 ruling 2 before it). + * The port the CHILD says it bound — read out of the child's OWN output (#12526). * - * ## Why this file needs it at all - * - * The spawn below passes `--dev`, and `serve.ts` reads - * `portAutoShiftAllowed = flags.dev || NODE_ENV === 'development'` — `flags.dev` - * ALONE opens the auto-select branch, whatever `NODE_ENV` is. So a port taken - * between `randomPort()`'s bind probe and this spawn does NOT fail the boot the - * way it does in `serve-node-env-production-default.e2e.test.ts` (no `--dev` - * there, so unset `NODE_ENV` defaults to production and a taken port is a hard - * `exit 1` that `portContentionError()` can name). Here `getAvailablePort()` - * silently hops the child onto the next free port and it reports itself READY. - * - * That is the strictly worse direction: a GREEN boot on the wrong port. Every - * request this file makes afterwards goes to `port` — the port it reserved and - * no longer owns — so it measures whatever else took it. Measured on this tree - * with a neighbour holding the reserved port: reserved 34259, child bound - * 34260, boot green, and the file's own next request was answered - * `{"iAm":"A NEIGHBOURING AGENT DEV SERVER, not os serve"}`. - * - * ⚠️ The `--dev` responsible has been on this spawn line since `83e6016fa` — it - * long predates #11707/#12459, which changed `NODE_ENV` and never touched it. - * This was never read, not newly introduced. + * The spawn asks for `KERNEL_ASSIGNED_PORT` (#24342), so this is the only way + * this file learns its port: nothing is drawn before the spawn, so nothing can + * be taken between a pick and the child's bind. That race used to cost more + * here than anywhere: `--dev` alone opens `serve`'s auto-shift branch, so a + * lost race was a GREEN boot on another port, and this file's requests then + * reached whatever held the one it had drawn. * * Two patterns because either one alone can be absent: the structured log obeys * `OS_LOG_LEVEL`, and the banner line is what survives when it does not. @@ -284,28 +270,6 @@ function boundPort(output: string): string | null { return match ? match[1] : null; } -/** - * A lost port race, said out loud — the failure this file used to hide behind a - * green boot (#12526). - */ -function portDriftError(reserved: string, bound: string, out: string, err: string): Error { - return new Error( - `PORT DRIFT: this file reserved port ${reserved}, but the child bound ${bound}.\n` - + `\`os serve -p ${reserved} --dev\` passes \`--dev\`, so \`serve.ts\`'s ` - + `\`portAutoShiftAllowed = flags.dev || NODE_ENV === 'development'\` opened the auto-select ` - + `branch and \`getAvailablePort()\` hopped the child off the port that was asked for. The ` - + `boot SUCCEEDED — on the wrong port.\n` - + `⛔ Something else took ${reserved} between \`randomPort()\`'s bind probe and the spawn. That ` - + `is a HOST race (several agents share one container), not a verdict about the code under ` - + `test — but it is NOT harmless here: every assertion below talks to ${reserved}, which is ` - + `now that other process, so continuing would measure a stranger rather than this boot.\n` - + `⛔ Do not "fix" this by following the child to ${bound}: the point is that the port this ` - + `file uses and the port the child bound must be the SAME port. Re-run this file in ` - + `isolation; if it reproduces there, the port is genuinely held.\n` - + `--- stdout ---\n${out.slice(-4000)}\n--- stderr ---\n${err.slice(-4000)}`, - ); -} - async function stop(child: ChildProcessWithoutNullStreams): Promise { if (child.exitCode !== null || child.signalCode !== null) return; await new Promise((done) => { @@ -352,12 +316,11 @@ describe('#7915: a stdio MCP boot writes nothing but protocol frames to stdout', JSON.stringify({ name: 'mcp-stdout-purity-e2e-fixture', private: true, type: 'module' }, null, 2), 'utf8', ); - port = randomPort(); // Boot 1 — mint a real key through the product route, against a FILE db so // boot 2 sees the same row (`:memory:` would not survive the restart). const first = await boot({ OS_DATABASE_URL: join(dir, 'probe.db') }, /Server is ready/); - const base = `http://localhost:${port}/api/v1`; + const base = `http://localhost:${first.port}/api/v1`; // ⭐ #15898 — the fifth and last file of the family #15653 repaired. // `boot()`'s `child.on('exit')` handler feeds the READINESS promise ONLY — it @@ -379,7 +342,7 @@ describe('#7915: a stdio MCP boot writes nothing but protocol frames to stdout', child: first.child, transcript, label: 'serve-stdio-stdout-purity', - what: `${what} on port ${port}`, + what: `${what} on port ${first.port}`, }, request, ); diff --git a/packages/cli/test/serve-view-container-read.integration.test.ts b/packages/cli/test/serve-view-container-read.integration.test.ts index df0153f2c18..7efeb9ec712 100644 --- a/packages/cli/test/serve-view-container-read.integration.test.ts +++ b/packages/cli/test/serve-view-container-read.integration.test.ts @@ -45,12 +45,11 @@ import { join } from 'node:path'; import { CLI, E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, TSX, childEnv, - portContentionError, - portDriftError, probeThroughChild, - randomPort, + readKernelAssignedPort, } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; @@ -114,13 +113,15 @@ async function buildArtifact(dir: string): Promise { interface LiveServe { child: ChildProcessWithoutNullStreams; + /** The port the child bound: kernel-assigned, read back off its own banner (#24342). */ + port: number; output: () => string; } /** Boot `os serve` in `dir` with `args`; the source entry seeds the dev admin either way. */ -function bootServe(dir: string, args: string[], port: string): Promise { +function bootServe(dir: string, args: string[]): Promise { return new Promise((resolveBoot, rejectBoot) => { - const child = spawn(TSX, [CLI, 'serve', ...args, '-p', port], { + const child = spawn(TSX, [CLI, 'serve', ...args, '-p', KERNEL_ASSIGNED_PORT], { cwd: dir, stdio: ['pipe', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). @@ -137,12 +138,12 @@ function bootServe(dir: string, args: string[], port: string): Promise { + const settle = (verdict: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, output: () => out }); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, port: verdict, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -151,15 +152,12 @@ function bootServe(dir: string, args: string[], port: string): Promise { out += String(d); // The child is the authority on the port it bound (#12525). - if (READY.test(out)) settle(portDriftError(out, what, port)); + if (READY.test(out)) settle(readKernelAssignedPort(out, what)); }; child.stdout.on('data', onData); child.stderr.on('data', onData); child.on('exit', (code) => - settle( - portContentionError(out, what, port) - ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), - ), + settle(new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -218,9 +216,8 @@ const LOCALES = ['zh-CN', 'en']; /** Boot, sign in as the seeded admin, read every by-name view in every locale, stop. */ async function read(dir: string, args: string[]): Promise<{ reading: Reading; output: string }> { - const port = randomPort(); - const base = `http://localhost:${port}/api/v1`; - const serve = await bootServe(dir, args, port); + const serve = await bootServe(dir, args); + const base = `http://localhost:${serve.port}/api/v1`; try { const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, { method: 'POST', diff --git a/packages/cli/test/start-signal-forwarding.e2e.test.ts b/packages/cli/test/start-signal-forwarding.e2e.test.ts index e3004ec236d..2b6108d03d7 100644 --- a/packages/cli/test/start-signal-forwarding.e2e.test.ts +++ b/packages/cli/test/start-signal-forwarding.e2e.test.ts @@ -45,9 +45,9 @@ * The subject is the supervisor's wiring in `src/commands/start.ts`, which the * built entry runs byte for byte from `dist/`, so the source entry measures the * same code without a build prerequisite. The `development` posture it pins - * re-opens `serve`'s port auto-shift; that is harmless here because the port is - * read back out of the child's own banner (`boundPortFromBanner`) rather than - * assumed from what this file asked for. The built entry was measured too, by + * re-opens `serve`'s port auto-shift; that is harmless here because each boot + * asks for a kernel-assigned port and reads the one it bound back out of the + * child's own banner (`readKernelAssignedPort`). The built entry was measured too, by * hand, before and after the repair (the PR that landed this records both). * * Each child gets its own process group (`detached: true`) so `afterEach` can @@ -64,9 +64,9 @@ import { fileURLToPath } from 'node:url'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { childEnv, - boundPortFromBanner, + KERNEL_ASSIGNED_PORT, portIsFree, - reservePort, + readKernelAssignedPort, } from './helpers/serve-process.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -100,13 +100,13 @@ const ARTIFACT = JSON.stringify({ type Command = 'start' | 'dev'; -/** The argv that boots `command` on `artifact` at `port`, with no Console. */ -function argvFor(command: Command, artifact: string, port: number): string[] { +/** The argv that boots `command` on `artifact` at a kernel-assigned port, with no Console. */ +function argvFor(command: Command, artifact: string): string[] { // `start` declares `--ui` with `allowNo` (default on); `dev` declares it // default-off and rejects `--no-ui`. Both therefore boot without a Console. return command === 'start' - ? ['start', '--artifact', artifact, '--port', String(port), '--no-ui'] - : ['dev', '--artifact', artifact, '--port', String(port)]; + ? ['start', '--artifact', artifact, '--port', KERNEL_ASSIGNED_PORT, '--no-ui'] + : ['dev', '--artifact', artifact, '--port', KERNEL_ASSIGNED_PORT]; } /** @@ -176,12 +176,11 @@ function boot(command: Command): Promise { workdir = dir; const artifact = join(dir, 'objectstack.json'); writeFileSync(artifact, ARTIFACT); - const port = reservePort(); return new Promise((resolveBoot, rejectBoot) => { const parent = spawn( process.execPath, - ['--import', TSX_LOADER, RUN_DEV_JS, ...argvFor(command, artifact, port)], + ['--import', TSX_LOADER, RUN_DEV_JS, ...argvFor(command, artifact)], { cwd: dir, env: childEnv({ @@ -211,12 +210,12 @@ function boot(command: Command): Promise { if (!ready && BANNER_TAIL.test(output)) { ready = true; clearTimeout(timer); - const readback = boundPortFromBanner(output); - if (readback.state !== 'bound') { - rejectBoot(new Error(`os ${command}: banner unreadable (${readback.state}).\n--- output ---\n${output}`)); + const port = readKernelAssignedPort(output, `os ${command}`); + if (port instanceof Error) { + rejectBoot(port); return; } - resolveBoot({ parent, port: readback.port, output: () => output, exited }); + resolveBoot({ parent, port, output: () => output, exited }); } }; parent.stdout?.on('data', onData);