From fa7eda4891e8d39a1ba81746df48e53ba836e50b Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Mon, 26 Oct 2026 13:50:44 +0900 Subject: [PATCH 1/2] test(cli): let the forged sibling-env start bind its own port freeLoopbackPort() releases its probe before the pinned child binds; a port taken in between makes the pinned start wait out port reclaim past the 45 s Windows deadline with no runtime record (dev run 40183909309). The forged-env case checks sibling admission, not a port number, so it now binds through config.port 0. Timeouts report the child's output and last record. --- tests/cli/cli-start-journal-order.test.ts | 65 +++++++++++++++++------ 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/tests/cli/cli-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index f7a98f1f359..786be7bdd47 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -306,13 +306,23 @@ function freeLoopbackPort(): number { return port; } +/** + * Spawn `ocx start` and wait for its runtime record and settled readiness. + * + * `port` pins the listener with `--port`. A pinned port comes from freeLoopbackPort(), which + * releases its probe before the child binds, so another process on the runner can take it in + * between; a hard-pinned start then waits out port reclaim (up to 60 s) before it binds and + * publishes, which outlasts OWNER_WAIT_MS with the child alive and no record. Pass `undefined` + * when the case does not need a predetermined port: the child keeps config.port and binds its + * own, and the runtime record supplies the port it chose. + */ async function startSibling( env: Record, siblingHome: string, - port: number, + port: number | undefined, cwd: string, ): Promise<{ child: ReturnType; runtime: { pid: number; port: number; siblingOfPort?: number } }> { - const child = Bun.spawn([process.execPath, cliPath, "start", "--port", String(port)], { + const child = Bun.spawn([process.execPath, cliPath, "start", ...(port === undefined ? [] : ["--port", String(port)])], { cwd, env, stdout: "pipe", @@ -320,25 +330,45 @@ async function startSibling( }); children.push(child); const runtimePath = join(siblingHome, "runtime-port.json"); - const runtime = await waitFor(async () => { - if (child.exitCode !== null) { - throw new Error(`sibling exited ${child.exitCode}: ${await new Response(child.stderr).text()}`); - } - if (!existsSync(runtimePath)) return null; - try { - const value = JSON.parse(readFileSync(runtimePath, "utf8")) as { pid: number; port: number; siblingOfPort?: number }; - return value.pid === child.pid && value.port === port ? value : null; - } catch { - return null; - } - }, "sibling runtime record"); + let lastRecord: string | null = null; + let runtime: { pid: number; port: number; siblingOfPort?: number }; + try { + runtime = await waitFor(async () => { + if (child.exitCode !== null) { + throw new Error(`sibling exited ${child.exitCode}: ${await new Response(child.stderr).text()}`); + } + if (!existsSync(runtimePath)) return null; + try { + lastRecord = readFileSync(runtimePath, "utf8"); + const value = JSON.parse(lastRecord) as { pid: number; port: number; siblingOfPort?: number }; + const portOk = Number.isInteger(value.port) && value.port >= 1 && value.port <= 65535 + && (port === undefined || value.port === port); + return value.pid === child.pid && portOk ? value : null; + } catch { + return null; + } + }, "sibling runtime record"); + } catch (error) { + // Report what the child was doing, which the bare deadline message cannot. + if (child.exitCode === null) child.kill("SIGKILL"); + const tail = (stream: unknown) => Promise.race([ + new Response(stream as ReadableStream).text().then(text => text.slice(-4000)).catch(() => ""), + Bun.sleep(2000).then(() => ""), + ]); + const [stdout, stderr] = await Promise.all([tail(child.stdout), tail(child.stderr)]); + throw new Error( + `${(error as Error).message} (requested port ${port ?? "config"}, pid ${child.pid}, last record ${lastRecord ?? "none"})\n` + + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + ); + } + const readyPort = runtime.port; // /readyz settles after the startup sync. Without the sibling gate that sync is exactly // what rewrote the shared config.toml, so the bytes are compared only once it has settled, // and it has to settle as "ready": a sibling that wrote nothing has nothing to fail. let settled: string | undefined; await waitFor(async () => { try { - const response = await fetch(`http://127.0.0.1:${port}/readyz`, { signal: AbortSignal.timeout(500) }); + const response = await fetch(`http://127.0.0.1:${readyPort}/readyz`, { signal: AbortSignal.timeout(500) }); const body = await response.json() as { status?: string; pid?: number }; if (body.pid !== child.pid || body.status === undefined || body.status === "pending") return null; settled = body.status; @@ -477,11 +507,12 @@ describe("a sibling instance leaves the live owner's client routing alone", () = test("a direct start cannot claim sibling status with only a forged port env", async () => { const fx = fixture(); const forgedOwnerPort = freeLoopbackPort(); - const childPort = freeLoopbackPort(); + // Sibling admission is the claim here, not a particular listener number, so the child binds + // its own port (config.port 0) instead of a released probe port another process may take. const launched = await startSibling( { ...fx.env, OCX_SIBLING_OF_PORT: String(forgedOwnerPort) }, fx.ocxHome, - childPort, + undefined, fx.root, ); expect(launched.runtime.siblingOfPort).toBeUndefined(); From 0df85516842a498b8aed7aa65ad36611bf534275 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Mon, 26 Oct 2026 13:59:14 +0900 Subject: [PATCH 2/2] test(cli): keep sibling-start diagnostics secret-free and early-exit safe Log only allowlisted runtime-record fields (the record carries the per-process attestationSecret), rethrow an early-exit error unchanged since its stderr is already consumed, and catch synchronous Response construction failures in the bounded tail reader. --- tests/cli/cli-start-journal-order.test.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/cli/cli-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index 786be7bdd47..f8a1899b4a7 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -330,17 +330,20 @@ async function startSibling( }); children.push(child); const runtimePath = join(siblingHome, "runtime-port.json"); + // Allowlisted fields only: the record also carries the per-process attestationSecret. let lastRecord: string | null = null; + let exitedEarly = false; let runtime: { pid: number; port: number; siblingOfPort?: number }; try { runtime = await waitFor(async () => { if (child.exitCode !== null) { + exitedEarly = true; throw new Error(`sibling exited ${child.exitCode}: ${await new Response(child.stderr).text()}`); } if (!existsSync(runtimePath)) return null; try { - lastRecord = readFileSync(runtimePath, "utf8"); - const value = JSON.parse(lastRecord) as { pid: number; port: number; siblingOfPort?: number }; + const value = JSON.parse(readFileSync(runtimePath, "utf8")) as { pid: number; port: number; hostname?: string; siblingOfPort?: number }; + lastRecord = JSON.stringify({ pid: value.pid, port: value.port, hostname: value.hostname, siblingOfPort: value.siblingOfPort }); const portOk = Number.isInteger(value.port) && value.port >= 1 && value.port <= 65535 && (port === undefined || value.port === port); return value.pid === child.pid && portOk ? value : null; @@ -349,12 +352,20 @@ async function startSibling( } }, "sibling runtime record"); } catch (error) { + // An early exit already reported its stderr and consumed the stream. + if (exitedEarly) throw error; // Report what the child was doing, which the bare deadline message cannot. if (child.exitCode === null) child.kill("SIGKILL"); - const tail = (stream: unknown) => Promise.race([ - new Response(stream as ReadableStream).text().then(text => text.slice(-4000)).catch(() => ""), - Bun.sleep(2000).then(() => ""), - ]); + const tail = async (stream: unknown): Promise => { + try { + return await Promise.race([ + new Response(stream as ReadableStream).text().then(text => text.slice(-4000)), + Bun.sleep(2000).then(() => ""), + ]); + } catch { + return ""; + } + }; const [stdout, stderr] = await Promise.all([tail(child.stdout), tail(child.stderr)]); throw new Error( `${(error as Error).message} (requested port ${port ?? "config"}, pid ${child.pid}, last record ${lastRecord ?? "none"})\n`