From 80c683e90a6fc78c8bd99f01dcc553884447ceac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:23:27 +0000 Subject: [PATCH 1/3] test(cli): runServe() spawns on a kernel-assigned port and returns the bound port (WIP) Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- packages/cli/test/helpers/serve-process.ts | 42 ++++++++++++++++--- .../test/serve-app-runtime-hooks.e2e.test.ts | 4 +- ...-boot-diagnostics-noise-budget.e2e.test.ts | 4 +- .../test/serve-boot-diagnostics.e2e.test.ts | 11 ++--- ...serve-cluster-driver-diagnosis.e2e.test.ts | 4 +- .../cli/test/serve-no-artifact.e2e.test.ts | 12 +++--- ...-organizations-host-resolution.e2e.test.ts | 11 ++--- ...ve-organizations-mount-failure.e2e.test.ts | 14 +++---- ...erve-package-declared-capabilities.test.ts | 4 +- ...rve-package-registry-always-on.e2e.test.ts | 37 ++++++++-------- .../cli/test/serve-port-readback.e2e.test.ts | 38 +++++++++++++---- 11 files changed, 112 insertions(+), 69 deletions(-) diff --git a/packages/cli/test/helpers/serve-process.ts b/packages/cli/test/helpers/serve-process.ts index 1cb9cb4d94a..96c6b5e1ecc 100644 --- a/packages/cli/test/helpers/serve-process.ts +++ b/packages/cli/test/helpers/serve-process.ts @@ -873,6 +873,13 @@ export function childEnv( export interface ServeRun { stdout: string; stderr: string; + /** + * The port the child BOUND, read back out of its own ready banner — the only + * number a caller may address the child on. `undefined` when the run ended + * without a complete banner (a boot that died, or a `waitFor` that matched + * an earlier line, as `serve-no-artifact`'s refusals do). + */ + port: number | undefined; } /** @@ -922,9 +929,18 @@ function portOf(args: string[]): string | undefined { * free port and boots clean. Read the docblock there for why a green is the * more expensive outcome of the two. * - * Only the SECOND needs the caller to have passed `--port`; both read the + * Both apply only to a caller that passed `--port` itself; both read the * child's own output for the port they name, never the harness's expectation. * + * ⭐ A caller that passes NO `--port` gets {@link KERNEL_ASSIGNED_PORT} (#24342): + * the child's own `listen()` picks the port, so no released number crosses a + * gap and there is nothing to contend for or drift from. The drift check then + * becomes the read-back: a complete banner must name the non-zero port the + * child bound ({@link readKernelAssignedPort} — an unreadable `API:` row or an + * announced `localhost:0` rejects), and that port is the returned `port`. + * ⛔ So a test of the port doors themselves (`OS_PORT`, `PORT`, the built-in + * default) cannot ride this default; it passes the port it means explicitly. + * * `waitFor` is matched against **stdout and stderr together** (#7915). `serve` * writes every human line — banner, boot progress, kernel logs — to stderr now, * because its stdout belongs to the MCP stdio transport when one is mounted; @@ -941,8 +957,10 @@ export function runServe( // exercises use `??` and an empty string is not nullish. opts: { waitFor: RegExp; timeoutMs?: number; config?: string; env?: Record }, ): Promise { + const requested = portOf(args); + const argv = requested === undefined ? [...args, '--port', KERNEL_ASSIGNED_PORT] : args; return new Promise((resolveRun, rejectRun) => { - const child = spawn(TSX, [CLI, 'serve', opts.config ?? 'objectstack.config.ts', ...args], { + const child = spawn(TSX, [CLI, 'serve', opts.config ?? 'objectstack.config.ts', ...argv], { cwd, // `childEnv`, never a bare `...process.env` — see its header for the // measured reason (commit 1ddda1d00). @@ -980,7 +998,20 @@ export function runServe( return; } const what = 'os serve (bin/run-dev.js, via runServe)'; - const contended = portContentionError(stdout + stderr, what, portOf(args)); + const output = stdout + stderr; + if (requested === undefined) { + // Kernel-assigned (#24342): no `portContentionError()` — this child was + // handed no number to lose, so a port it could not bind is not that race. + if (boundPortFromBanner(output).state === 'no-banner') { + resolveRun({ stdout, stderr, port: undefined }); + return; + } + const bound = readKernelAssignedPort(output, what); + if (bound instanceof Error) rejectRun(bound); + else resolveRun({ stdout, stderr, port: bound }); + return; + } + const contended = portContentionError(output, what, requested); if (contended) { rejectRun(contended); return; @@ -989,12 +1020,13 @@ export function runServe( // succeeded. `bin/run-dev.js` pins `NODE_ENV=development`, so the branch // above (a loud refusal to bind) is not the one these children take; this // is. See `portDriftError`. - const drifted = portDriftError(stdout + stderr, what, portOf(args)); + const drifted = portDriftError(output, what, requested); if (drifted) { rejectRun(drifted); return; } - resolveRun({ stdout, stderr }); + const readback = boundPortFromBanner(output); + resolveRun({ stdout, stderr, port: readback.state === 'bound' ? readback.port : undefined }); }; const timer = setTimeout( diff --git a/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts b/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts index d95f14a21ab..045a0fe79dd 100644 --- a/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts +++ b/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts @@ -27,7 +27,7 @@ import { promisify } from 'node:util'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { childEnv, runServe, randomPort, CLI, TSX } from './helpers/serve-process.js'; +import { childEnv, runServe, CLI, TSX } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; const execFileP = promisify(execFile); @@ -95,7 +95,7 @@ afterAll(() => { describe('os serve — an app booted from its artifact keeps its module code (#4095)', () => { it('runs onEnable, so the declared script action has a handler', async () => { - const { stdout, stderr } = await runServe(dir, ['--port', randomPort()], { + const { stdout, stderr } = await runServe(dir, [], { waitFor: /Press Ctrl\+C to stop/, }); diff --git a/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts b/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts index 058fbecec7d..b9e3809fa90 100644 --- a/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts +++ b/packages/cli/test/serve-boot-diagnostics-noise-budget.e2e.test.ts @@ -34,7 +34,7 @@ import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; -import { childEnv, randomPort, runServe, type ServeRun } from './helpers/serve-process.js'; +import { childEnv, runServe, type ServeRun } from './helpers/serve-process.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); // One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs` @@ -187,7 +187,7 @@ beforeAll(async () => { join(dir, 'src', 'actions', 'index.ts'), "export { ResolveTicketAction, EscalateTicketAction } from './ticket.actions.js';\n", ); - boot = await runServe(dir, ['--port', randomPort()], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: RUN_TIMEOUT_MS - 60_000 }); + boot = await runServe(dir, [], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: RUN_TIMEOUT_MS - 60_000 }); }, RUN_TIMEOUT_MS); afterAll(() => { diff --git a/packages/cli/test/serve-boot-diagnostics.e2e.test.ts b/packages/cli/test/serve-boot-diagnostics.e2e.test.ts index 95301cf0d5a..e97518ea958 100644 --- a/packages/cli/test/serve-boot-diagnostics.e2e.test.ts +++ b/packages/cli/test/serve-boot-diagnostics.e2e.test.ts @@ -30,7 +30,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runServe, randomPort } from './helpers/serve-process.js'; +import { runServe } from './helpers/serve-process.js'; /** * A stack whose only interesting property is that booting it MUST log a @@ -83,10 +83,8 @@ describe('os serve — boot-phase logger output (#4012)', () => { it( 'prints the boot WARN a plugin logged while the banner was being assembled', async () => { - // Random high port: never contend with a dev server this machine is - // already running (AGENTS.md multi-agent discipline §8). - const port = randomPort(); - const { stdout, stderr } = await runServe(dir, ['--port', port], { + // No `--port`: `runServe()` hands the child a kernel-assigned one (#24342). + const { stdout, stderr } = await runServe(dir, [], { waitFor: /Press Ctrl\+C to stop/, }); @@ -111,8 +109,7 @@ describe('os serve — boot-phase logger output (#4012)', () => { // none of them from boot — not even the kernel's own plain // `logger.debug('Triggering kernel:ready hook')`. At a verbose level the // quiet window no longer opens at all. - const port = randomPort(); - const { stdout, stderr } = await runServe(dir, ['--port', port, '--log-level', 'debug'], { + const { stdout, stderr } = await runServe(dir, ['--log-level', 'debug'], { waitFor: /Press Ctrl\+C to stop/, }); diff --git a/packages/cli/test/serve-cluster-driver-diagnosis.e2e.test.ts b/packages/cli/test/serve-cluster-driver-diagnosis.e2e.test.ts index cf97e09e2ac..19d54140cc6 100644 --- a/packages/cli/test/serve-cluster-driver-diagnosis.e2e.test.ts +++ b/packages/cli/test/serve-cluster-driver-diagnosis.e2e.test.ts @@ -96,7 +96,7 @@ import { describe, it, expect, afterAll } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runServe, randomPort } from './helpers/serve-process.js'; +import { runServe } from './helpers/serve-process.js'; /** `OS_CLUSTER_DRIVER` for every boot below — see the header for why `custom`. */ const DRIVER = 'custom'; @@ -224,7 +224,7 @@ function writeApp(prefix: string, fixture: Fixture): string { /** Boot the fixture app and return everything the child printed, both streams. */ async function boot(prefix: string, fixture: Fixture): Promise { const dir = writeApp(prefix, fixture); - const { stdout, stderr } = await runServe(dir, ['--port', randomPort()], { + const { stdout, stderr } = await runServe(dir, [], { // The boot dies at `defineCluster()`; `runServe` resolves on exit either // way, so this only shortens the happy path rather than deciding it. waitFor: /is not registered/, diff --git a/packages/cli/test/serve-no-artifact.e2e.test.ts b/packages/cli/test/serve-no-artifact.e2e.test.ts index 44f4dd4a310..c93411d0ec5 100644 --- a/packages/cli/test/serve-no-artifact.e2e.test.ts +++ b/packages/cli/test/serve-no-artifact.e2e.test.ts @@ -54,7 +54,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from 'node: import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { runServe, randomPort } from './helpers/serve-process.js'; +import { runServe } from './helpers/serve-process.js'; /** An ordinary app: manifest + an object, exactly what `os init` scaffolds. */ const APP_CONFIG = ` @@ -159,7 +159,7 @@ describe('os serve — boots without a compiled artifact (#4085)', () => { it( 'serves an app defined only by objectstack.config.ts', async () => { - const { stdout, stderr } = await runServe(appDir, ['--port', randomPort()], { + const { stdout, stderr } = await runServe(appDir, [], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: 240_000, }); @@ -195,7 +195,7 @@ describe('os serve — boots without a compiled artifact (#4085)', () => { it( 'serves a config authored through defineStack(), the shape every real project has (#3887)', async () => { - const { stdout, stderr } = await runServe(defineStackDir, ['--port', randomPort()], { + const { stdout, stderr } = await runServe(defineStackDir, [], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: 240_000, }); @@ -236,7 +236,7 @@ describe('os serve — boots without a compiled artifact (#4085)', () => { it( 'serves a config with no application at all', async () => { - const { stdout, stderr } = await runServe(bareDir, ['--port', randomPort()], { + const { stdout, stderr } = await runServe(bareDir, [], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: 240_000, }); @@ -262,7 +262,7 @@ describe('os serve — boots without a compiled artifact (#4085)', () => { async () => { const emptyDir = mkdtempSync(join(tmpdir(), 'os-nothing-to-serve-')); try { - const { stdout, stderr } = await runServe(emptyDir, ['--port', randomPort()], { + const { stdout, stderr } = await runServe(emptyDir, [], { // It never boots, so wait on something that can only appear on the way // down; the harness resolves on exit regardless. waitFor: /Nothing to serve/, @@ -295,7 +295,7 @@ describe('os serve — boots without a compiled artifact (#4085)', () => { it( 'serves on, and says why, when the config carries an app it cannot register', async () => { - const { stdout, stderr } = await runServe(orphanDir, ['--port', randomPort()], { + const { stdout, stderr } = await runServe(orphanDir, [], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: 240_000, }); diff --git a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts index 04a365e0365..6980863636a 100644 --- a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts +++ b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts @@ -34,7 +34,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runServe, randomPort } from './helpers/serve-process.js'; +import { runServe } from './helpers/serve-process.js'; const CONFIG = ` export default { @@ -159,8 +159,7 @@ describe('os serve — enterprise organizations resolution (cloud#1013)', () => it( 'boots a walled posture with the package installed in the APP, not the framework', async () => { - const port = randomPort(); - const { stdout, stderr } = await runServe(appWithPackage, ['--port', port], { + const { stdout, stderr } = await runServe(appWithPackage, [], { waitFor: /Press Ctrl\+C to stop/, env: { ...SERVE_ENV }, timeoutMs: 240_000, @@ -189,8 +188,7 @@ describe('os serve — enterprise organizations resolution (cloud#1013)', () => // fail-fast into a lenient skip. An app that requests isolation without // the enterprise runtime must still die rather than serve traffic with // the organization wall inactive. - const port = randomPort(); - const { stdout, stderr } = await runServe(appWithoutPackage, ['--port', port], { + const { stdout, stderr } = await runServe(appWithoutPackage, [], { waitFor: /Press Ctrl\+C to stop/, env: { ...SERVE_ENV }, timeoutMs: 240_000, @@ -218,8 +216,7 @@ describe('os serve — enterprise organizations resolution (cloud#1013)', () => // this change that single environment variable was enough to boot the // organization wall off a package the app had never declared, so whether // ADR-0093 D5 fired came down to HOW the process was started. - const port = randomPort(); - const { stdout, stderr } = await runServe(appWithoutPackage, ['--port', port], { + const { stdout, stderr } = await runServe(appWithoutPackage, [], { waitFor: /Press Ctrl\+C to stop/, env: { ...SERVE_ENV, NODE_PATH: hoistedStore }, timeoutMs: 240_000, diff --git a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts index d4d91d3d9e5..1fb7b499a28 100644 --- a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts +++ b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts @@ -40,7 +40,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runServe, randomPort } from './helpers/serve-process.js'; +import { runServe } from './helpers/serve-process.js'; const CONFIG = ` export default { @@ -156,8 +156,7 @@ describe('os serve — organizations import stage vs mount stage (#4818)', () => it( 'refuses to boot with the ADR-0093 D5 "could not be loaded" diagnosis', async () => { - const port = randomPort(); - const { stdout, stderr } = await runServe(appAbsent, ['--port', port], { + const { stdout, stderr } = await runServe(appAbsent, [], { waitFor: BANNER_RE, env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined }, timeoutMs: 240_000, @@ -183,8 +182,7 @@ describe('os serve — organizations import stage vs mount stage (#4818)', () => // The escape hatch keeps its one legitimate meaning: "the capability is // absent and I accept running without it". Pinned so the stage split // cannot regress it. - const port = randomPort(); - const { stdout, stderr } = await runServe(appAbsent, ['--port', port], { + const { stdout, stderr } = await runServe(appAbsent, [], { waitFor: BANNER_RE, env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' }, timeoutMs: 240_000, @@ -203,8 +201,7 @@ describe('os serve — organizations import stage vs mount stage (#4818)', () => it( "surfaces the plugin's own error verbatim and exits, without the absent-package wording", async () => { - const port = randomPort(); - const { stdout, stderr } = await runServe(appRefusing, ['--port', port], { + const { stdout, stderr } = await runServe(appRefusing, [], { waitFor: BANNER_RE, env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined }, timeoutMs: 240_000, @@ -241,8 +238,7 @@ describe('os serve — organizations import stage vs mount stage (#4818)', () => async () => { // THE issue. Before the fix this booted with a warning: an env var // silently overrode whatever gate the plugin was enforcing. - const port = randomPort(); - const { stdout, stderr } = await runServe(appRefusing, ['--port', port], { + const { stdout, stderr } = await runServe(appRefusing, [], { waitFor: BANNER_RE, env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' }, timeoutMs: 240_000, diff --git a/packages/cli/test/serve-package-declared-capabilities.test.ts b/packages/cli/test/serve-package-declared-capabilities.test.ts index cca40e63cf1..3d11fdd8a13 100644 --- a/packages/cli/test/serve-package-declared-capabilities.test.ts +++ b/packages/cli/test/serve-package-declared-capabilities.test.ts @@ -44,7 +44,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { randomPort, runServe } from './helpers/serve-process.js'; +import { runServe } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; /** The ready line. The banner it opens (`Plugins: N loaded`, `Flows:`) prints AFTER it. */ @@ -114,7 +114,7 @@ function bannerPlugins(output: string): string[] { } const boot = async (name: string, waitFor: RegExp, extra: string[] = []) => { - const run = await runServe(dirs[name], ['--port', randomPort(), ...extra], { waitFor, timeoutMs: 150_000 }); + const run = await runServe(dirs[name], extra, { waitFor, timeoutMs: 150_000 }); return run.stdout + run.stderr; }; diff --git a/packages/cli/test/serve-package-registry-always-on.e2e.test.ts b/packages/cli/test/serve-package-registry-always-on.e2e.test.ts index 95d68e0ed91..527bbd6a4b2 100644 --- a/packages/cli/test/serve-package-registry-always-on.e2e.test.ts +++ b/packages/cli/test/serve-package-registry-always-on.e2e.test.ts @@ -45,10 +45,9 @@ import { E2E_SECRET_KEY, TSX, childEnv, - portContentionError, - portDriftError, + KERNEL_ASSIGNED_PORT, probeThroughChild, - randomPort, + readKernelAssignedPort, runServe, } from './helpers/serve-process.js'; @@ -111,6 +110,8 @@ function bannerMentions(output: string, plugin: string): number { interface LiveServe { child: ChildProcessWithoutNullStreams; + /** The port the child's kernel assigned it, read back out of its banner (#24342). */ + port: number; /** Everything the child has printed so far, both streams. */ output: () => string; } @@ -123,9 +124,9 @@ const dirs: string[] = []; * the install probe signs in as; on the second boot the admin already exists * in the database and the seed leaves it alone. */ -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 (commit 1ddda1d00). @@ -142,12 +143,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 = (result: Error | number) => { if (settled) return; settled = true; clearTimeout(timer); - if (err) rejectBoot(err); - else resolveBoot({ child, output: () => out }); + if (result instanceof Error) rejectBoot(result); + else resolveBoot({ child, port: result, output: () => out }); }; const timer = setTimeout( () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), @@ -155,16 +156,13 @@ 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 (#12525). - if (READY.test(out)) settle(portDriftError(out, what, port)); + // The child is the only authority on the port its kernel assigned (#24342). + 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)}`)), ); }); } @@ -240,10 +238,8 @@ describe('#19387: a stock app gets the package service — an API-created packag const dir = fixture('pkg-registry-stock-', appConfig('stockboot', undefined)); dirs.push(dir); const db = join(dir, 'probe.db'); - const port = randomPort(); - const base = `http://localhost:${port}/api/v1`; - - const first = await bootServe(dir, port, db); + const first = await bootServe(dir, db); + let base = `http://localhost:${first.port}/api/v1`; const token = await signIn(first, base); installStatus = await probe(first, 'the install probe', async () => { const r = await fetch(`${base}/packages`, { @@ -261,7 +257,8 @@ describe('#19387: a stock app gets the package service — an API-created packag firstBoot = first.output(); await stop(first.child); - const second = await bootServe(dir, port, db); + const second = await bootServe(dir, db); + base = `http://localhost:${second.port}/api/v1`; const token2 = await signIn(second, base); restartRead = await probe(second, 'the post-restart read probe', async () => { const r = await fetch(`${base}/packages/${SURVIVOR_ID}`, { @@ -302,7 +299,7 @@ describe('#19387: an app that declares `marketplace` still mounts exactly one Pa beforeAll(async () => { const dir = fixture('pkg-registry-declarer-', appConfig('declarer', ['marketplace'])); dirs.push(dir); - const run = await runServe(dir, ['-p', randomPort()], { + const run = await runServe(dir, [], { waitFor: READY, env: { OS_DATABASE_URL: join(dir, 'probe.db') }, }); diff --git a/packages/cli/test/serve-port-readback.e2e.test.ts b/packages/cli/test/serve-port-readback.e2e.test.ts index d00e065c02c..cf504a3f3f6 100644 --- a/packages/cli/test/serve-port-readback.e2e.test.ts +++ b/packages/cli/test/serve-port-readback.e2e.test.ts @@ -63,7 +63,7 @@ import { boundPortFromBanner, holdPort, portDriftError, - randomPort, + readKernelAssignedPort, runServe, } from './helpers/serve-process.js'; @@ -174,6 +174,27 @@ describe('#12525: the child\'s REAL port is read back out of its own banner', () }); }); + describe('a KERNEL-ASSIGNED boot (#24342): no number to drift from, so the read-back is the check', () => { + // `runServe()` with no `--port` hands the child port 0. These are the + // refusals that stand in for the drift comparison there; without them the + // check would read vacuously green on every boot of that family. + it('reads the bound port out of a complete banner', () => { + expect(readKernelAssignedPort(bannerFor(41234), 'os serve')).toBe(41234); + }); + + it('refuses a banner that announces the REQUEST, localhost:0', () => { + const error = readKernelAssignedPort(bannerFor(0), 'os serve'); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('localhost:0'); + }); + + it('refuses an unreadable banner and a missing one', () => { + const external = bannerFor(41234, ' ➜ API: https://app.example.com/'); + expect((readKernelAssignedPort(external, 'os serve') as Error).message).toContain('CANNOT READ BACK'); + expect(readKernelAssignedPort('', 'os serve')).toBeInstanceOf(Error); + }); + }); + describe('an UNREADABLE banner is an error, not a shrug', () => { // A skip here would restore exactly the failure this file removes: the // harness looks, finds nothing, and hands back a green. @@ -337,16 +358,19 @@ describe('#12525: the child\'s REAL port is read back out of its own banner', () }); it( - 'POSITIVE CONTROL: a real boot on a free port reads back as exactly that port', + 'POSITIVE CONTROL: a real kernel-assigned boot reads back as the port runServe() returns', async () => { - const port = randomPort(); - const { stdout, stderr } = await runServe(bareDir, ['--port', port], { + // No `--port`, so the child takes a kernel-assigned one (#24342) — there + // is no released number left to lose a race over. The run RESOLVING is + // already half the assertion: `runServe()` rejects a complete banner it + // cannot read a non-zero port out of, so a parse that stopped matching the + // LIVE banner reddens here, not in the forced drift below. + const { stdout, stderr, port } = await runServe(bareDir, [], { waitFor: /Press Ctrl\+C to stop/, timeoutMs: 240_000, }); - // The run RESOLVING is already half the assertion — `runServe()` rejects - // on a drift now, so reaching this line means the check did not fire. - expect(boundPortFromBanner(stdout + stderr)).toEqual({ state: 'bound', port: Number(port) }); + expect(port).toBeGreaterThan(0); + expect(boundPortFromBanner(stdout + stderr)).toEqual({ state: 'bound', port }); }, 240_000, ); From e059995d97eef0d95c142da10d40c14e565b60aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:26:14 +0000 Subject: [PATCH 2/3] test(cli): the released-port pin's population widens to every CLI spawn; unconverted files ledgered for slice 2 (WIP) Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- packages/cli/test/helpers/serve-process.ts | 49 ++-- ...released-port-production-spawn.pin.test.ts | 266 +++++++++++------- 2 files changed, 191 insertions(+), 124 deletions(-) diff --git a/packages/cli/test/helpers/serve-process.ts b/packages/cli/test/helpers/serve-process.ts index 96c6b5e1ecc..fc6e05c4ffb 100644 --- a/packages/cli/test/helpers/serve-process.ts +++ b/packages/cli/test/helpers/serve-process.ts @@ -212,7 +212,7 @@ function probeBind(want: number): number | null { * nobody re-examined the draw until a real run in this fleet went red on * `✗ Port 49402 is already in use`. **Do not write another one here.** * - * ## Where the race is closed, and where this draw is still allowed (#24295) + * ## Where the race is closed, and where this draw is still allowed (#24295, #24342) * * The residual race reached `main` once: an hourly run went red on * `start-mcp-stdio-early-initialize.integration.test.ts`, whose `os start` @@ -220,18 +220,23 @@ function probeBind(want: number): number | null { * refuses to auto-shift by design, so for that column the gap is a red, and it * is now CLOSED rather than narrowed: such a spawn passes * {@link KERNEL_ASSIGNED_PORT} and reads the port back with - * {@link readKernelAssignedPort}, so no number crosses a gap at all. - * `released-port-production-spawn.pin.test.ts` holds it: a file that names the - * production-capable entry (`bin/run.js`) may call this or `randomPort()` only - * as often as its ledger entry there allows — zero without one, and an entry is - * a file whose every spawn of that entry is development posture. + * {@link readKernelAssignedPort}, so no number crosses a gap at all. A + * 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). + * `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 — `runServe()` and every other child of - * `bin/run-dev.js`, which pins `NODE_ENV=development` before argv is - * parsed. A lost race there is a hop to the next port, not an exit, and - * `portDriftError()` below is what turns that hop into a red; + * • 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`). * @@ -312,10 +317,11 @@ 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 columns it may still serve (the - * development column and instruments — never a production-capable spawn) are - * all documented on `reservePort()` above, and there is no second mechanism - * hiding behind this name. + * 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 + * documented on `reservePort()` above, and there is no second mechanism hiding + * behind this name. */ export function randomPort(): string { return String(reservePort()); @@ -551,10 +557,11 @@ export type BannerPortReadback = * the sentence that made this population look half its real size. Which files * are in the loud row is ⛔ not counted here either — an earlier count here * ("the ONLY file") went stale as `bin/run.js` spawners were added. The loud row - * can only be reached through `bin/run.js`, so - * `released-port-production-spawn.pin.test.ts` enumerates every file that names - * it, and holds each of them to {@link KERNEL_ASSIGNED_PORT} unless its ledger - * entry there says why not (#24295). + * can only be reached through `bin/run.js`, but a lost race reds a correct tree + * in the silent rows too, so `released-port-production-spawn.pin.test.ts` + * enumerates every file that spawns the CLI — either entry, either posture — + * and holds each of them to {@link KERNEL_ASSIGNED_PORT} unless its ledger entry + * there says why not (#24295, #24342). * * And the port `runServe()` hands over is an opaque element of an argv list, so * until this read-back the harness never learned the child's real one. A test @@ -592,7 +599,8 @@ 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 — the race is still real there, and this is what makes it a red. + * 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. * * ## Why `unreadable` is an ERROR and not a skip * @@ -609,7 +617,8 @@ export function boundPortFromBanner(output: string): BannerPortReadback { * `/Nothing to serve/` case does. Such a child announced no bound port, so * there is nothing to compare and this returns `null`. Every `runServe()` * caller that boots successfully today waits for `Press Ctrl+C to stop` — the - * banner tail — so all of them are covered. + * banner tail — so all of them are covered: by this check when the caller + * passed its own `--port`, by {@link readKernelAssignedPort} when it passed none. */ export function portDriftError( output: string, 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 35bdee0b03d..0a3ed839fe6 100644 --- a/packages/cli/test/released-port-production-spawn.pin.test.ts +++ b/packages/cli/test/released-port-production-spawn.pin.test.ts @@ -1,58 +1,50 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #24295 — no file that can spawn a PRODUCTION-posture child draws a released - * port. + * #24295, widened by #24342 — no file that spawns the CLI draws a released port. * * ## The race this pin keeps closed * * `reservePort()` / `randomPort()` (`helpers/serve-process.ts`) bind * `0.0.0.0:0`, read the port, CLOSE the listener and hand the number over, so - * the port is unheld between the pick and the child's own `listen()`. A child - * in production posture refuses to auto-shift by design (`serve.ts`: - * `✗ Port is already in use.`, exit 1), so for that column a port taken - * inside the gap is a RED. It reached `main`: an hourly full run went red on - * `start-mcp-stdio-early-initialize.integration.test.ts`, whose `os start` - * child found port 45195 taken. The closed mechanism is `KERNEL_ASSIGNED_PORT` - * (`--port 0`) with `readKernelAssignedPort()`: the child's own kernel picks - * the port at its own bind, so no number crosses a gap. + * the port is unheld between the pick and the child's own `listen()`. A port + * taken inside that gap reds a correct tree in BOTH columns: a production- + * posture child refuses to auto-shift (`✗ Port is already in use.`, exit + * 1 — an hourly full run went red on exactly that, port 45195), and a + * development-posture child hops to the next port, which `portDriftError()` + * then turns into a red on purpose. The closed mechanism is + * `KERNEL_ASSIGNED_PORT` (`--port 0`) with `readKernelAssignedPort()`: the + * child's own kernel picks the port at its own bind, so no number crosses a + * gap. `runServe()` does both when its caller passes no `--port`. * - * ## The population: every file that names `bin/run.js` + * ## The population: every file that spawns the CLI * - * A production-posture child can only come from the BUILT entry. The source - * entry `bin/run-dev.js` sets `process.env.NODE_ENV = 'development'` - * unconditionally before argv is parsed, so every tsx child — `runServe()`'s, - * and an `os start` whose `serve` child inherits that value — is in the - * development column, where `serve` auto-shifts instead of exiting. So the - * population is read off the entry, not off the spawn line: any file whose - * CODE spells `bin/run.js` (a comment naming it is not code), in either of the - * two spellings this directory uses — + * Read off what the file's CODE (a comment is not code) can reach, not off the + * spawn line, so it over-approximates on purpose: * - * - one string that ends with it: `resolve(HERE, '../bin/run.js')`; - * - path SEGMENTS in one call: `join(PACKAGE_ROOT, 'bin', 'run.js')`. A - * call's string-literal arguments are split on `/` and read in order, and - * the entry's segments must all appear among them, in order; a non-literal - * argument contributes nothing. Both segments must sit in ONE call, so a - * file holding `'bin'` and `'run.js'` in unrelated expressions is not - * admitted, while the first spelling is the one-argument case of the same - * reading. + * - it spells an entry, `bin/run.js` (the built one, the only door to + * production posture) or `bin/run-dev.js` (the tsx one, which pins + * `NODE_ENV=development` before argv is parsed), in either spelling this + * directory uses — one string that ends with it + * (`resolve(HERE, '../bin/run.js')`), or path SEGMENTS in one call + * (`join(PACKAGE_ROOT, 'bin', 'run.js')`: a call's string-literal arguments + * are split on `/` and read in order, the entry's segments must all appear + * among them, in order, and a non-literal argument contributes nothing); + * - or it imports one of the harness's CLI spawners, `CLI` (the tsx entry) + * or `runServe()`, by name, under an alias, or as a namespace member. + * `TSX` alone is not one: it runs whatever script it is handed. * - * That over-approximates on purpose — a file that names the built entry may - * spawn it in development posture too (`--dev`, `os dev`) — and those files - * sit in {@link OUTSIDE_THE_PRODUCTION_COLUMN} with their reason and their - * EXACT draw count, so a new draw added to one of them is reclassified rather - * than waved through. + * A file in the population may draw only as often as its + * {@link RELEASED_DRAW_LEDGER} entry says — zero without one — so a new draw + * in a ledgered file is reclassified rather than waved through. * * ## What this pin does not see — named, not discovered * - * 1. The development column: every tsx child still draws, and a lost race - * there is a drift that `portDriftError()` turns into a red. That race is - * real and unclosed; it is outside this pin by its population. - * 2. A production-capable entry spelled some other way — a packed tarball's - * bin, `dist/` run directly — or its directory held in a variable before - * the file name is joined on (`join(BIN_DIR, 'run.js')`). Add the suffix - * to {@link PRODUCTION_CAPABLE_ENTRIES} the day a test spawns one. - * 3. A released draw written by hand (`listen(0)`, read, close) instead of + * 1. An entry spelled some other way — a packed tarball's bin, `dist/` run + * directly — or its directory held in a variable before the file name is + * joined on (`join(BIN_DIR, 'run.js')`). Add the suffix to + * {@link CLI_ENTRIES} the day a test spawns one. + * 2. A released draw written by hand (`listen(0)`, read, close) instead of * calling the helper. The census counts calls of the two helpers only. * * ⛔ Not a gate script and not a CI step: an ordinary unit-tier test in the @@ -67,54 +59,75 @@ import { describe, expect, it } from 'vitest'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); -/** The suffixes of the entries that can produce a production-posture child. */ -const PRODUCTION_CAPABLE_ENTRIES = ['bin/run.js'] as const; +/** The suffixes of the CLI's two entries: the built one and the tsx one. */ +const CLI_ENTRIES = ['bin/run.js', 'bin/run-dev.js'] as const; /** The same entries as the path segments a `join()` spells them in. */ -const ENTRY_SEGMENTS: readonly (readonly string[])[] = PRODUCTION_CAPABLE_ENTRIES.map((entry) => entry.split('/')); +const ENTRY_SEGMENTS: readonly (readonly string[])[] = CLI_ENTRIES.map((entry) => entry.split('/')); /** The released-port draws `helpers/serve-process.ts` exports. */ const RELEASED_PORT_DRAWS: ReadonlySet = new Set(['reservePort', 'randomPort']); +/** The harness exports that spawn the CLI: the tsx entry itself, and `runServe()`. */ +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)?$/; -const DEVELOPMENT_ONLY_DRIFT_RED = - 'every spawn here is development posture (bin/run.js with --dev), which auto-shifts; a lost race ' - + 'is a drift that this file\'s own port read-back reports as a red — the development-column ' - + 'residual reservePort() documents, not the production column this pin closes.'; +/** + * 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 name `bin/run.js` and still draw, each with the EXACT number of - * draw calls it may make and why none of them feeds a production-posture spawn. - * - * An entry is earned one of two ways, and its `why` says which: every spawn the - * file makes is development posture, or a production-posture site needs the - * number BEFORE the spawn and has no closed alternative (`KERNEL_ASSIGNED_PORT` - * read back afterwards, or a port held for the whole boot). None of the second - * kind exists today. + * 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. */ -const OUTSIDE_THE_PRODUCTION_COLUMN: Readonly> = { - 'dev-boot-output-order.integration.test.ts': { - draws: 1, - why: '`os dev` under a pty — development posture, which auto-shifts; the file reads no port, so ' - + 'a lost race is neither a red nor a wrong reading.', - }, - 'serve-mcp-capability-collision.e2e.test.ts': { draws: 1, why: DEVELOPMENT_ONLY_DRIFT_RED }, - 'serve-mcp-stdio-answers.e2e.test.ts': { draws: 1, why: DEVELOPMENT_ONLY_DRIFT_RED }, - 'serve-stdio-stdout-purity.e2e.test.ts': { draws: 1, why: DEVELOPMENT_ONLY_DRIFT_RED }, +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 }, }; interface Census { - /** CODE spells a production-capable entry: one string, or one call's segments. */ - namesProductionEntry: boolean; + /** CODE spells a CLI entry, or imports one of the harness's CLI spawners. */ + spawnsCli: boolean; /** The 1-based line of every released-port draw call. */ drawLines: number[]; } /** * Do this call's string-literal arguments, read as path segments in order, - * spell a production-capable entry — `join(PACKAGE_ROOT, 'bin', 'run.js')`? + * spell a CLI entry — `join(PACKAGE_ROOT, 'bin', 'run.js')`? */ function spellsEntryInSegments(call: ts.CallExpression): boolean { const segments = call.arguments @@ -143,29 +156,41 @@ function censusOf(fileName: string, source: string): Census { // Local names a draw arrives under: the exported names themselves, plus any // alias an import from the harness module binds. A namespace import needs no - // entry: `harness.randomPort()` is read off the member name below. + // entry: `harness.randomPort()` is read off the member name below. A CLI + // spawner imported from the harness — by name, under an alias, or as a + // namespace member — puts the file in the population on its own. const drawNames = new Set(RELEASED_PORT_DRAWS); + const namespaces = new Set(); + let spawnsCli = false; for (const statement of file.statements) { if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; if (!HARNESS_MODULE.test(statement.moduleSpecifier.text)) continue; const bindings = statement.importClause?.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text); if (bindings && ts.isNamedImports(bindings)) { for (const specifier of bindings.elements) { - if (RELEASED_PORT_DRAWS.has((specifier.propertyName ?? specifier.name).text)) drawNames.add(specifier.name.text); + const exported = (specifier.propertyName ?? specifier.name).text; + if (RELEASED_PORT_DRAWS.has(exported)) drawNames.add(specifier.name.text); + if (CLI_SPAWNERS.has(exported)) spawnsCli = true; } } } - let namesProductionEntry = false; const drawLines: number[] = []; const visit = (node: ts.Node): void => { if ( (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateLiteralToken(node)) - && PRODUCTION_CAPABLE_ENTRIES.some((entry) => node.text.endsWith(entry)) + && CLI_ENTRIES.some((entry) => node.text.endsWith(entry)) + ) { + spawnsCli = true; + } + if (ts.isCallExpression(node) && spellsEntryInSegments(node)) spawnsCli = true; + if ( + ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) + && namespaces.has(node.expression.text) && CLI_SPAWNERS.has(node.name.text) ) { - namesProductionEntry = true; + spawnsCli = true; } - if (ts.isCallExpression(node) && spellsEntryInSegments(node)) namesProductionEntry = true; if (ts.isCallExpression(node)) { const callee = node.expression; const isDraw = ts.isIdentifier(callee) @@ -181,7 +206,7 @@ function censusOf(fileName: string, source: string): Census { ts.forEachChild(node, visit); }; visit(file); - return { namesProductionEntry, drawLines }; + return { spawnsCli, drawLines }; } /** Every `.ts` source under this directory, keyed by its POSIX path relative to it. */ @@ -202,9 +227,9 @@ function liveTree(): Map { } /** The files a census puts in this pin's population, with the draws each makes. */ -function productionCapable(tree: Map): Map { +function cliSpawners(tree: Map): Map { const out = new Map(); - for (const [path, census] of tree) if (census.namesProductionEntry) out.set(path, census.drawLines); + for (const [path, census] of tree) if (census.spawnsCli) out.set(path, census.drawLines); return out; } @@ -252,8 +277,38 @@ const FIXTURES: Array<{ name: string; source: string; population: boolean; draws draws: 1, }, { - name: 'the source entry, which pins development posture', + name: 'the source entry, which pins development posture — a lost race there drifts and reds', source: `import { randomPort } ${FROM_HARNESS}\nconst CLI = resolve(HERE, ${SOURCE});\nconst port = randomPort();\n`, + population: true, + draws: 1, + }, + { + name: 'runServe() from the harness, handed a released draw', + source: `import { randomPort, runServe } ${FROM_HARNESS}\nawait runServe(dir, ['--port', randomPort()], opts);\n`, + population: true, + draws: 1, + }, + { + name: 'the harness CLI under an alias', + source: `import { CLI as ENTRY, randomPort } ${FROM_HARNESS}\nspawn(TSX, [ENTRY, 'serve', '-p', randomPort()]);\n`, + population: true, + draws: 1, + }, + { + name: 'the harness CLI as a namespace member', + source: `import * as harness ${FROM_HARNESS}\nspawn(harness.TSX, [harness.CLI, 'serve', '-p', harness.randomPort()]);\n`, + population: true, + draws: 1, + }, + { + name: 'TSX alone, running a script that is not the CLI', + source: `import { TSX, randomPort } ${FROM_HARNESS}\nspawn(TSX, [CHILD_SCRIPT, randomPort()]);\n`, + population: false, + draws: 1, + }, + { + name: 'a local CLI that is no entry and no harness import', + source: `import { randomPort } ${FROM_HARNESS}\nconst CLI = someOtherTool();\nspawn(CLI, [randomPort()]);\n`, population: false, draws: 1, }, @@ -268,7 +323,7 @@ const FIXTURES: Array<{ name: string; source: string; population: boolean; draws name: 'the source entry spelled in path segments', source: `import { randomPort } ${FROM_HARNESS}\nconst CLI = join(PACKAGE_ROOT, ${BIN_SEGMENT}, ${RUN_DEV_SEGMENT});\n` + `const port = randomPort();\n`, - population: false, + population: true, draws: 1, }, { @@ -287,40 +342,42 @@ const FIXTURES: Array<{ name: string; source: string; population: boolean; draws }, ]; -describe('#24295: no file that can spawn a production-posture child draws a released port', () => { +describe('#24295 / #24342: no file that spawns the CLI draws a released port', () => { it('the census tells apart every shape it has to', () => { for (const fixture of FIXTURES) { const census = censusOf(`${fixture.name}.ts`, fixture.source); - expect(census.namesProductionEntry, `${fixture.name}: population`).toBe(fixture.population); + expect(census.spawnsCli, `${fixture.name}: population`).toBe(fixture.population); expect(census.drawLines, `${fixture.name}: draws`).toHaveLength(fixture.draws); } }); - it('the census reaches the live tree: the built entry and the draw are both found', () => { + it('the census reaches the live tree: every way of spawning the CLI, and the draw, are found', () => { const tree = liveTree(); - const population = productionCapable(tree); + const population = cliSpawners(tree); // ⭐ Positive controls, so an empty finding list below cannot mean "read nothing". - // The file whose red filed #24295 spawns the built entry… - expect(population.has('start-mcp-stdio-early-initialize.integration.test.ts'), 'the red site is not in the population') - .toBe(true); - // …and the two files that spell it in path segments are read as naming it too. - for (const segmented of [ - 'published-entry-stderr-error-listener.test.ts', - 'published-entry-stderr-nonblocking.e2e.test.ts', + for (const [file, how] of [ + // The file whose red filed #24295 spawns the built entry… + ['start-mcp-stdio-early-initialize.integration.test.ts', 'spawns bin/run.js'], + // …two files spell it in path segments… + ['published-entry-stderr-error-listener.test.ts', 'spells bin/run.js in segments'], + ['published-entry-stderr-nonblocking.e2e.test.ts', 'spells bin/run.js in segments'], + // …and the development column (#24342): a runServe() caller, a harness-CLI spawner, a run-dev.js speller. + ['serve-boot-diagnostics.e2e.test.ts', 'calls runServe()'], + ['package-install-local-handlers.integration.test.ts', 'spawns the harness CLI'], + ['start-signal-forwarding.e2e.test.ts', 'spells bin/run-dev.js'], ]) { - expect(population.has(segmented), `${segmented} spells bin/run.js in segments but is not in the population`) - .toBe(true); + expect(population.has(file), `${file} ${how} but is not in the population`).toBe(true); } // …and the instrument that measures the draw itself still draws, outside the population. const instrument = tree.get('serve-port-bind-probe.test.ts'); expect(instrument?.drawLines.length ?? 0, 'the draw census finds no draw in the bind-probe instrument').toBeGreaterThan(0); - expect(instrument?.namesProductionEntry).toBe(false); + expect(instrument?.spawnsCli).toBe(false); }); - it('every file that names bin/run.js draws exactly what its ledger entry allows — zero without one', () => { + it('every file that spawns the CLI draws exactly what its ledger entry allows — zero without one', () => { const offenders: string[] = []; - for (const [path, drawLines] of productionCapable(liveTree())) { - const allowed = OUTSIDE_THE_PRODUCTION_COLUMN[path]?.draws ?? 0; + for (const [path, drawLines] of cliSpawners(liveTree())) { + const allowed = RELEASED_DRAW_LEDGER[path]?.draws ?? 0; if (drawLines.length === allowed) continue; offenders.push( `${path}: ${drawLines.length} released-port draw(s) (line ${drawLines.join(', ')}), ledger allows ${allowed}`, @@ -328,18 +385,19 @@ describe('#24295: no file that can spawn a production-posture child draws a rele } expect( offenders, - 'A file that can spawn a production-posture child draws a released port. Such a child refuses to ' - + 'auto-shift, so a port taken between reservePort()/randomPort() and its bind is a red. Spawn it ' - + 'with `--port KERNEL_ASSIGNED_PORT` and read the port back with readKernelAssignedPort() ' - + '(helpers/serve-process.ts); a port the test must know but the child must not bind is a ' - + 'holdPort() kept for the whole boot. A development-posture-only file, or a site with no closed ' - + 'alternative, goes in OUTSIDE_THE_PRODUCTION_COLUMN with its reason and exact draw count.', + 'A file that spawns the CLI draws a released port. A port taken between reservePort()/randomPort() ' + + 'and the child\'s bind is a red in either posture: production refuses to auto-shift, development ' + + 'shifts and the read-back reds the drift. Spawn with `--port KERNEL_ASSIGNED_PORT` and read the ' + + 'port back with readKernelAssignedPort() (helpers/serve-process.ts), or call runServe() with no ' + + '`--port` and use the port it returns; a port the test must know but the child must not bind is ' + + 'a holdPort() kept for the whole boot. A site with no closed alternative goes in ' + + 'RELEASED_DRAW_LEDGER with its reason and exact draw count.', ).toEqual([]); }); it('every ledger entry still names a file in the population', () => { - const population = productionCapable(liveTree()); - const stale = Object.keys(OUTSIDE_THE_PRODUCTION_COLUMN).filter((path) => !population.has(path)); - expect(stale, 'remove these entries: the file is gone or no longer names bin/run.js').toEqual([]); + const population = cliSpawners(liveTree()); + const stale = Object.keys(RELEASED_DRAW_LEDGER).filter((path) => !population.has(path)); + expect(stale, 'remove these entries: the file is gone or no longer spawns the CLI').toEqual([]); }); }); From 1886250897a9b4319977a1ecf8627ebfc4b9cb85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 17:48:37 +0000 Subject: [PATCH 3/3] test(cli): keep the pin in the unit tier: spell the runServe call shape in pieces Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .../test/released-port-production-spawn.pin.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 0a3ed839fe6..9eb562190d2 100644 --- a/packages/cli/test/released-port-production-spawn.pin.test.ts +++ b/packages/cli/test/released-port-production-spawn.pin.test.ts @@ -242,6 +242,9 @@ const FROM_HARNESS = `from ${Q}./helpers/serve-process.js${Q};`; const BIN_SEGMENT = `${Q}bin${Q}`; const RUN_SEGMENT = `${Q}run.js${Q}`; const RUN_DEV_SEGMENT = `${Q}run-dev.js${Q}`; +// Spelled in pieces as well: the literal call shape in this file's code would put it in the +// `integration` tier, whose predicate (`vitest-tiers.ts`) reads `runServe` + `(` in code position. +const RUN_SERVE = 'runServe'; const FIXTURES: Array<{ name: string; source: string; population: boolean; draws: number }> = [ { @@ -283,8 +286,8 @@ const FIXTURES: Array<{ name: string; source: string; population: boolean; draws draws: 1, }, { - name: 'runServe() from the harness, handed a released draw', - source: `import { randomPort, runServe } ${FROM_HARNESS}\nawait runServe(dir, ['--port', randomPort()], opts);\n`, + name: 'the harness runServe helper, handed a released draw', + source: `import { randomPort, ${RUN_SERVE} } ${FROM_HARNESS}\nawait ${RUN_SERVE}(dir, ['--port', randomPort()], opts);\n`, population: true, draws: 1, }, @@ -361,8 +364,8 @@ describe('#24295 / #24342: no file that spawns the CLI draws a released port', ( // …two files spell it in path segments… ['published-entry-stderr-error-listener.test.ts', 'spells bin/run.js in segments'], ['published-entry-stderr-nonblocking.e2e.test.ts', 'spells bin/run.js in segments'], - // …and the development column (#24342): a runServe() caller, a harness-CLI spawner, a run-dev.js speller. - ['serve-boot-diagnostics.e2e.test.ts', 'calls runServe()'], + // …and the development column (#24342): a runServe caller, a harness-CLI spawner, a run-dev.js speller. + ['serve-boot-diagnostics.e2e.test.ts', 'calls the runServe helper'], ['package-install-local-handlers.integration.test.ts', 'spawns the harness CLI'], ['start-signal-forwarding.e2e.test.ts', 'spells bin/run-dev.js'], ]) { @@ -388,7 +391,7 @@ describe('#24295 / #24342: no file that spawns the CLI draws a released port', ( 'A file that spawns the CLI draws a released port. A port taken between reservePort()/randomPort() ' + 'and the child\'s bind is a red in either posture: production refuses to auto-shift, development ' + 'shifts and the read-back reds the drift. Spawn with `--port KERNEL_ASSIGNED_PORT` and read the ' - + 'port back with readKernelAssignedPort() (helpers/serve-process.ts), or call runServe() with no ' + + 'port back with readKernelAssignedPort() (helpers/serve-process.ts), or give the runServe helper no ' + '`--port` and use the port it returns; a port the test must know but the child must not bind is ' + 'a holdPort() kept for the whole boot. A site with no closed alternative goes in ' + 'RELEASED_DRAW_LEDGER with its reason and exact draw count.',