From 9e5a917eddb8d1f1ab32dc0421e415ddb71ff3e9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:25:44 +0900 Subject: [PATCH 1/8] fix(test): promote Bun 1.4.4 with independent runner role verification --- .github/actions/setup-project-bun/action.yml | 4 +-- .github/workflows/ci.yml | 10 +++--- CONTRIBUTING.md | 6 ++-- docs-site/src/content/docs/contributing.md | 6 ++-- package.json | 2 +- structure/runtime.md | 4 +-- tests/ci-workflows/ci-scope-gaps.test.ts | 2 +- tests/ci-workflows/install-scripts.test.ts | 6 ++-- tests/test-layout-tooling.test.ts | 34 ++++++++++++++++---- 9 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1ab22dd1dc9..cf28f6f9d43 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -8,8 +8,8 @@ description: >- # (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to # 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after # finalizers ran inside an earlier call and spin past its own timeout (#4956). -# Rejoin only after the upstream runner crash is fixed and a green lane=all -# dispatch proves the unified version; then delete testRunnerBun and this role input. +# Both roles now default to the package's 1.4.4 security transition, retaining role validation. +# Promotion requires a green full cross-platform lane=all run on the exact candidate head. inputs: role: description: Bun role to install (runtime or test-runner). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028c28ca318..7bcd0eaa3f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -578,12 +578,10 @@ jobs: printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" - # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; - # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 - # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs - # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). - # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch - # proves the unified version, then delete testRunnerBun and the action's role input. + # Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json. + # Keep role validation after the historical isolate preload and spawnSync failures + # (#6713, #4956). Promotion requires a green full cross-platform lane=all run on + # the exact candidate head; focused local results do not replace that proof. # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d4aa94f580..bbcd248f8f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks -The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses +The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7fe0b09831b..d0021b7f12a 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` -The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. +The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. Install that version locally if it is missing; the resolver never downloads it. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` by default. If a full run is disproportionately expensive for the task size, available machine resources, or concurrent worktrees, you must at least run focused regression tests diff --git a/package.json b/package.json index 89176da2851..a270427faf2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "trustedDependencies": [ "bun" ], - "testRunnerBun": "1.4.3", + "testRunnerBun": "1.4.4", "scripts": { "dev": "bun run src/cli/index.ts start", "dev:proxy": "bun run src/cli/index.ts start", diff --git a/structure/runtime.md b/structure/runtime.md index 43e071d457f..763f3e90c3d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ The minute sweep checks persisted activation deadlines locally; only missing dea ## Bun runtime and test runner -`package.json` pins the shipped npm runtime (`dependencies.bun`) to 1.4.4 and the test runner (`testRunnerBun`) to 1.4.3; `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. +`package.json` pins both the shipped npm runtime (`dependencies.bun`) and the test runner (`testRunnerBun`) to 1.4.4; the roles remain independently validated and `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload segfault (`load_preloads -> JSModuleLoader::loadModule -> JSPromise::status`, address 0x10): #4064 introduced 1.4.2 on 2026-09-09 and #4821 reverted it on 2026-09-17. It left 1.4.0 for 1.4.3 because of oven-sh/bun#44581 (#4956). Bun 1.4.0's `Bun.spawnSync` pointed the VM at its private wait loop, so a finalizer that ran during one call (an earlier file's stdout/stderr sink, a stream reader) released its poll on that loop; the cached loop's poll count drifted to zero and a later call stopped polling for its child, spinning past its own `timeout` until the 120-second batch deadline. In a multi-file `--isolate` process that depends on what earlier files left to finalize, so single-file attribution stayed clean. `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` reproduces the mechanism in a child under a bounded parent and fails on 1.4.0. #6713 run 37631558263 reproduces that 1.4.2 preload segfault on Linux 1/4, macOS 2/2 and Windows 9/9; single-file success does not prove the multi-file runner works. @@ -12,7 +12,7 @@ The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload se Test jobs, including single-file development-version validation, select `test-runner`. They also build dashboard fixtures; `gates` uploads a dashboard preview and checks release-helper syntax on that pin. Release packaging, Docker, installed-artifact and service smoke jobs, desktop shell and widget preparation use `runtime`. Shipped binaries compile through `process.execPath` in `scripts/build-standalone.ts` and `desktop/scripts/prepare-sidecar.ts` on the runtime pin. -`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading; package-script PATH shadowing cannot select the shipped runtime for tests. Rejoin only after an upstream fix and green unified `lane=all` proof; then remove the runner pin, resolver and CI role split. +`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading. The caller need not already run the test pin; only a version match permits reusing its executable. `tests/test-layout-tooling.test.ts` verifies matching/different caller fixtures, the selected isolate command, and refusal before spawning tests for a wrong-version override. The 1.4.4 security transition aligns the default versions while retaining the role fields, resolver and CI role checks. Promotion requires successful full cross-platform `lane=all` proof on the exact candidate head; focused local tests do not establish that proof. ## Resolved static model policy diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index f780d71aa90..0729ab6945d 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -65,7 +65,7 @@ describe("an edit to the setup action alone", () => { }); }); -// #6713: user runtimes and compiled artifacts need 1.4.2; isolate runners need the testRunnerBun pin (1.4.3, #4956). +// Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4. // Check the active setup at each test command, so a later role switch cannot evade the guard. const runsBunTests = (step: Step): boolean => /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 51e62b0b6fc..23ed7b7461e 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -94,10 +94,10 @@ describe("install scripts", () => { // and spun past its own timeout. Scratch run 38213764818 stalled 8 of 252 Linux shard 1/4 // batch-5 runs that way (CLI child a zombie, its pidfd armed but never polled). // tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts keeps the runner above it. - // Rejoin after the upstream runner crash is fixed and a green `lane=all` dispatch - // proves the unified version; then delete testRunnerBun and the setup action's role. + // The security transition now defaults both roles to 1.4.4. Keep their independent + // validation; promotion still requires a green full cross-platform lane=all candidate run. expect(pkg.dependencies?.bun).toBe("1.4.4"); - expect(pkg.testRunnerBun).toBe("1.4.3"); + expect(pkg.testRunnerBun).toBe("1.4.4"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index 354e3943fe0..cb4a7482819 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -7,6 +7,7 @@ import { repoPath, repoRoot } from "./helpers/repo-root"; import { listTestFiles, planMoves } from "../scripts/test-layout/plan"; import { runMove } from "../scripts/test-layout/move"; import { runVerify } from "../scripts/test-layout/verify"; +import * as runnerBun from "../scripts/lib/test-runner-bun"; import { testRunnerBun } from "../package.json"; import { anchors, @@ -469,24 +470,43 @@ describe("move end to end", () => { } }); - test("domain verification launches isolate tests through the pinned runner", () => { + const otherBunVersion = testRunnerBun === "1.4.3" ? "1.4.4" : "1.4.3"; + test.each([ + ["matching caller", testRunnerBun, testRunnerBun, false], + ["different caller", otherBunVersion, testRunnerBun, false], + ["wrong override version", otherBunVersion, otherBunVersion, true], + ] as const)("domain verification preserves the configured runner role: %s", (_name, currentVersion, overrideVersion, refused) => { const { root, cleanup } = scratchRepo(); const layoutPath = join(root, "scripts", "test-layout", "layout.json"); try { runMove({ root, domains: ["server", "providers"], dryRun: false, layoutPath, skipVerify: true, log: () => {} }); + const binaryName = process.platform === "win32" ? "bun.exe" : "bun"; + const pinnedRunner = join(root, "test-runner", binaryName); + const caller = currentVersion === testRunnerBun ? pinnedRunner : join(root, "runtime", binaryName); + const probes: string[] = []; + // Keep role resolution real, but version probes independent of the host and the spawn mock. + const select = spyOn(runnerBun, "getTestRunnerBun").mockImplementation(() => runnerBun.resolveTestRunnerBun({ + pin: testRunnerBun, currentVersion, execPath: caller, + env: { OCX_TEST_RUNNER_BUN: pinnedRunner }, pathEntries: [], homeDir: root, platform: process.platform, + probeVersion: binary => { probes.push(binary); return binary === pinnedRunner ? overrideVersion : undefined; }, + })); const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: Buffer.from(""), stderr: Buffer.from(""), } as ReturnType); try { - const report = runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); - expect(report.testExit).toBe(0); - // The test itself runs on the required pin, so its executable is the resolver's first choice. - expect(Bun.version).toBe(testRunnerBun); - expect(spawn.mock.calls.at(-1)?.[0]).toEqual([ - process.execPath, "test", "--isolate", "tests/server", "tests/providers", + const verify = () => runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); + if (refused) expect(verify).toThrow(`OCX_TEST_RUNNER_BUN reports ${overrideVersion}`); + else expect(verify()).toMatchObject({ ok: true, testExit: 0 }); + expect(select).toHaveBeenCalledTimes(1); + expect(probes).toEqual(currentVersion === testRunnerBun ? [] : [pinnedRunner]); + const testSpawns = spawn.mock.calls.filter(([command]) => Array.isArray(command) && command[1] === "test"); + expect(testSpawns.map(([command]) => command)).toEqual(refused ? [] : [ + [pinnedRunner, "test", "--isolate", "tests/server", "tests/providers"], ]); + if (!refused) expect(testSpawns[0]?.[1]).toMatchObject({ cwd: root, stdout: "inherit", stderr: "inherit" }); } finally { spawn.mockRestore(); + select.mockRestore(); } } finally { cleanup(); From 1deb67d274c8246df9ad539bff93f958782032cb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:38:09 +0900 Subject: [PATCH 2/8] docs: align Docker Bun comments with unified role defaults --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 82fe86f4cf1..54e0153e0f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,12 @@ # Keep the runtime aligned with package.json and pin the multi-platform image index. # Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS -# 3.131.1 roots without the compromised eMudhra CAs; CI tests run on 1.4.3 -# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; -# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync -# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the -# upstream crash fix passes a unified-version lane=all dispatch, then delete -# package.json's testRunnerBun pin and setup-project-bun's role input. +# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner +# defaults are both 1.4.4; keep package.json's testRunnerBun pin and +# setup-project-bun's role input independently validated. Promotion requires +# an exact-head cross-platform lane=all dispatch, including multi-file --isolate: +# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0 +# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks. ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d FROM ${BUN_IMAGE} AS manifest From 41f08fffd3e7dea64a6eed79af918aed74a153dd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:07:35 +0900 Subject: [PATCH 3/8] test: use a draining TLS origin for early upload replies --- .../claude-picker-upload.test.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..95c6351f04d 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -3,14 +3,39 @@ import { Agent, createServer, request } from "node:https"; import { connect, constants, type ClientHttp2Session, type ClientHttp2Stream } from "node:http2"; import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; +import { createServer as createTlsServer } from "node:tls"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; import { startPickerListener, type PickerListenerOptions } from "../../src/claude/intercept/picker-listener"; -async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => void, +async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => void) | "early-fin", options: Partial = {}) { const ca = createLocalInterceptCa(); const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); - const upstream = createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); + const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean }> = []; + // A wire-level origin sends the reply and FIN while continuing to drain input. Bun's HTTP + // server also tears down unfinished requests; overriding destroySoon alone does not prevent + // that reset racing ahead of the reply on Windows with a buffered upload. + const upstream = handler === "early-fin" ? createTlsServer({ cert: leaf.certPem, key: leaf.keyPem, allowHalfOpen: true }, socket => { + socket.on("error", () => socket.destroy()); + let headers = ""; + let replied = false; + socket.on("data", chunk => { + if (replied) return; + headers += chunk.toString("latin1"); + const end = headers.indexOf("\r\n\r\n"); + if (end < 0) return; + replied = true; + const early = headers.startsWith("POST /early "); + const body = early ? "early" : "ordinary"; + const length = /\r\ncontent-length: (\d+)\r\n/i.exec(headers.slice(0, end + 2)); + const requestBody = headers.slice(end + 4); + const observation = { requestComplete: length ? requestBody.length >= Number(length[1]) + : requestBody.endsWith("0\r\n\r\n"), flushed: false }; + if (early) earlyReplies.push(observation); + socket.once("finish", () => { observation.flushed = true; }); + socket.end(`HTTP/1.1 ${early ? "413 Payload Too Large" : "200 OK"}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`); + }); + }) : createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); const sockets = new Set(); for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); @@ -34,7 +59,7 @@ async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => v rejectUnauthorized: true, agent: h1Agent, path, method, headers: { Host: "claude.ai", ...headers } }); clients.push(req); return req; }; - return { logs, h2, h1, relay, + return { logs, h2, h1, relay, earlyReplies, async close() { const closed = clients.map(client => client.destroyed ? Promise.resolve() : observeClose(client)); await relay.close(); @@ -353,16 +378,7 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture((req, res) => { - // Answer like a real origin that replies early: keep reading the body and close with a FIN. - // Bun's own server would close this unfinished request with destroySoon(), a reset that - // Windows delivers before the reply bytes, so the relay could never read what it should relay. - req.on("error", () => res.destroy()); req.resume(); - const socket = req.socket; - socket.destroySoon = () => { socket.end(); return socket; }; - if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } - else res.end("ordinary"); - }); + const f = await fixture("early-fin"); try { const session = f.h2(); const upload = protocol === "h2" @@ -373,6 +389,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); + expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From a9c3d127d3728f50533df32504b787823b5220ee Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:09:36 +0900 Subject: [PATCH 4/8] test: confine draining origin to buffered HTTP/1 upload --- .../claude-integration/claude-picker-upload.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 95c6351f04d..d0bb998b873 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -378,7 +378,16 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture("early-fin"); + const f = await fixture(input === "buffered" ? "early-fin" : (req, res) => { + // Answer like a real origin that replies early: keep reading the body and close with a FIN. + // Bun's own server would close this unfinished request with destroySoon(), a reset that + // Windows delivers before the reply bytes, so the relay could never read what it should relay. + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } + else res.end("ordinary"); + }); try { const session = f.h2(); const upload = protocol === "h2" @@ -389,7 +398,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); - expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); + if (input === "buffered") expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From 7b92293bf421c5e39c20b0bc4bb500d08e7224a9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 13:14:46 +0000 Subject: [PATCH 5/8] fix(live): terminate stalled WebSocket closes on Bun 1.4.4 Force an unresponsive live sideband transport closed after the existing graceful timeout, retaining admission until close or observed CLOSED in both teardown paths. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../docs/ko/reference/proxy-formats.md | 4 + .../content/docs/reference/proxy-formats.md | 4 + scripts/test-layout/layout-server.json | 3 + scripts/test-layout/layout.json | 2 +- src/server/index/live-sideband.ts | 33 +-- src/server/ws-bridge.ts | 2 +- structure/runtime.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/server/server-live-close.test.ts | 188 ++++++++++++++++++ 9 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 scripts/test-layout/layout-server.json create mode 100644 tests/server/server-live-close.test.ts diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 32963d7f7c3..bf854d93543 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -294,6 +294,10 @@ call creation 이후 클라이언트는 다음의 지원되는 모든 inbound 프록시는 업스트림 join URL을 정규화한 뒤, 양방향 텍스트 및 바이너리 프레임을 투명하게 릴레이합니다. 업스트림 인증은 프록시가 소유한 상태로 유지되며, 클라이언트 프로토콜 헤더는 보존됩니다. +sideband 연결을 종료할 때 프록시는 업스트림의 정상 종료를 1초 동안 기다린 뒤, 응답하지 않는 +전송 연결을 강제로 닫습니다. 클라이언트 업그레이드가 실패한 경우에도 실제 업스트림 연결이 +닫힌 것을 확인한 후에만 해당 계정의 활성 통화 용량을 반환합니다. + call creation과 sideband join은 같은 OpenAI 계정으로 이루어져야 하며, 그렇지 않으면 업스트림이 join을 거부합니다(`404`). 두 요청 모두 Codex의 `session-id`와 `thread-id` 헤더를 실어 보냅니다. Pool 모드는 계정 선택을 그 쌍에 묶어 두므로(프로세스 로컬) 프록시에 도착한 join은 통화를 만든 계정을 그대로 쓰고, diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index fe6d7330b18..60609a1947a 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -90,6 +90,10 @@ returns 504, and client cancellation returns 499. Bun does not expose the exact cannot currently be forwarded precisely. A successful connection preserves the initial session frames in order. This handshake policy is separate from the Responses WebSocket transport. +When a sideband connection ends, the proxy allows one second for a graceful upstream +close before terminating an unresponsive transport. It releases the account's active-call +capacity only after that transport has closed, including when the client upgrade fails. + ## Undeclared tool calls On the Responses endpoint, when a request declares a tool catalog, a native passthrough reply diff --git a/scripts/test-layout/layout-server.json b/scripts/test-layout/layout-server.json new file mode 100644 index 00000000000..08f141dd13a --- /dev/null +++ b/scripts/test-layout/layout-server.json @@ -0,0 +1,3 @@ +{ + "server-live-close.test.ts": "server" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e2ec2449dfd..763e923508a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json"], + "explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-server.json"], "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", "owner-registry-isolation.test.ts": "config", diff --git a/src/server/index/live-sideband.ts b/src/server/index/live-sideband.ts index d0840bdc626..0f022efb6bc 100644 --- a/src/server/index/live-sideband.ts +++ b/src/server/index/live-sideband.ts @@ -184,6 +184,19 @@ function finalizeLiveSideband(ws: ServerWebSocket, upstream?: WebSocket) finally { releaseLiveSidebandAdmission(ws); } } +function forceCloseLiveSidebandUpstream(upstream: WebSocket): void { + // Bun 1.4.4 waits for the peer's closing handshake; another close() while + // CLOSING does not finish it. Stop the transport after the graceful window, + // but leave admission release to the close event or an observed CLOSED state. + try { + const socket = upstream as WebSocket & { terminate?: () => void }; + if (typeof socket.terminate === "function") socket.terminate(); + else upstream.close(1000, "upstream close timeout"); + } catch { + /* retain ownership until transport closure is confirmed */ + } +} + function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: WebSocket): void { if (ws.data.liveCloseFallback !== undefined) return; ws.data.liveCloseFallback = setTimeout(() => { @@ -193,13 +206,7 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: Web finalizeLiveSideband(ws, upstream); return; } - // A close frame was already sent below. Retry once, but never surrender - // native-main ownership while the authenticated transport remains live. - try { - upstream.close(1000, "upstream close timeout"); - } catch { - /* upstream is already unusable */ - } + forceCloseLiveSidebandUpstream(upstream); // Some implementations transition synchronously without delivering the // close event. That is still an observed CLOSED transport and is safe to // finalize. CONNECTING/CLOSING peers keep the lease so profile switching @@ -238,17 +245,13 @@ export function closeLiveSidebandBeforeUpgrade( releaseOnce(); return; } - try { - upstream.close(1000, "upstream close timeout"); - } catch { - /* retain ownership until CLOSED is observed */ - } + forceCloseLiveSidebandUpstream(upstream); if ((upstream.readyState as number) === 3) releaseOnce(); }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); try { upstream.close(code, reason); } catch { - /* the bounded fallback retries without releasing ownership */ + /* the bounded fallback stops the transport before releasing ownership */ } if ((upstream.readyState as number) === 3) releaseOnce(); } @@ -272,12 +275,12 @@ export function closeLiveSideband(ws: ServerWebSocket, code = 1000, reas // The sideband holds a native-main admission lease. Do not release it just // because the downstream left: its authenticated upstream remains live // until the close event arrives or the transport is observed CLOSED. The - // bounded fallback only retries close; it does not release ownership. + // bounded fallback terminates the transport; it cannot release a live lease. armLiveSidebandCloseFallback(ws, upstream); try { upstream.close(code, reason); } catch { - /* the fallback retries close without releasing ownership */ + /* the fallback stops the transport without releasing a live lease */ } } try { diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 6a10afe729e..a2ae495c10e 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -64,7 +64,7 @@ export interface WsData { liveUpstreamHandoff?: LiveSidebandUpstreamHandoff; /** Once teardown starts, ignore new client frames until the upstream closes. */ liveClosing?: boolean; - /** Schedules one bounded close retry without surrendering native-main ownership. */ + /** Terminates a stalled close without releasing ownership before transport closure. */ liveCloseFallback?: ReturnType; /** Turn/account ownership retained for the complete sideband socket lifetime. */ liveTurnAdmissionLease?: AdmissionLease; diff --git a/structure/runtime.md b/structure/runtime.md index 43e071d457f..b39ec30d8c8 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -430,7 +430,7 @@ Responses route normalization resolves provider summary defaults from the origin `src/server/index/serve-options.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade, and `src/server/index/live-sideband.ts` implements the bounded upstream dial. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504 and client cancellation returns 499; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. -The relay is transparent in both directions, and that includes the close: a downstream client's close code and reason are carried to the upstream through `clientCloseForUpstream`, which only substitutes 1000 for a code no endpoint may send and truncates the reason to the 123-byte control-frame limit. This matters to the caller, because a Frameless v3 client reads upstream 1000 as the session completing and any other code as a transport loss to reconnect. +The relay is transparent in both directions, and that includes the close: a downstream client's close code and reason are carried to the upstream through `clientCloseForUpstream`, which only substitutes 1000 for a code no endpoint may send and truncates the reason to the 123-byte control-frame limit. This matters to the caller, because a Frameless v3 client reads upstream 1000 as the session completing and any other code as a transport loss to reconnect. Both pre-upgrade and attached teardown allow one second for graceful closure, then call Bun's `terminate()`. Admission releases only after close/CLOSED; missing or failing termination cannot release a live lease, and a missing method receives one close retry. `tests/server/server-live-close.test.ts` covers silent real TCP peers on Bun 1.4.4, graceful peers and unconfirmed closure. `OCX_LIVE_FRAME_LOG` records both frame metadata and sideband lifecycle stages (`upstream-open`, `upstream-failed`, `relay-attached`, `relay-closed`) in one JSONL, content-free in both shapes. The lifecycle half is what separates a join that never reached this proxy from one whose upstream handshake was refused and from a live relay that carried nothing; frame records alone leave all three as an empty file. `tests/server/server-live-realtime-fixtures.test.ts` drives each sideband stage against de-identified Frameless v3 fixtures in `tests/fixtures/realtime-voice-sideband/` so a failure names the stage. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fed4bb7ebc7..fe59a8eb0e9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1167,6 +1167,7 @@ "combo-requested-effort.test.ts": "server", "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", + "server-live-close.test.ts": "server", "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", diff --git a/tests/server/server-live-close.test.ts b/tests/server/server-live-close.test.ts new file mode 100644 index 00000000000..ec6b2612a24 --- /dev/null +++ b/tests/server/server-live-close.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { ServerWebSocket } from "bun"; +import { createHash } from "node:crypto"; +import { createServer, type AddressInfo, type Socket } from "node:net"; +import { + attachLiveSidebandUpstream, + closeLiveSideband, + closeLiveSidebandBeforeUpgrade, +} from "../../src/server/index/live-sideband"; +import type { WsData } from "../../src/server/ws-bridge"; + +const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; +let savedProxyEnv: Record; +beforeEach(() => { + savedProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + for (const key of proxyKeys) delete process.env[key]; +}); +afterEach(() => { + for (const key of proxyKeys) { + if (savedProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedProxyEnv[key]; + } +}); + +type Phase = "before upgrade" | "attached"; +type TerminableSocket = WebSocket & { terminate(): void }; + +function closingOwner(phase: Phase, upstream: WebSocket) { + const releasedStates: number[] = []; + let finishes = 0; + const released = Promise.withResolvers(); + const release = () => { releasedStates.push(upstream.readyState); released.resolve(); }; + const data: WsData = { + kind: "live-sideband", liveUpstream: upstream, liveOpened: true, + liveUpstreamHandoff: { failure: () => undefined, take: () => ({ ok: true, frames: [] }) }, + liveTurnAdmissionLease: { release } as WsData["liveTurnAdmissionLease"], + liveFinish: () => { finishes++; }, + }; + const downstream = { data, readyState: WebSocket.OPEN, close() {}, send: () => 1 } as unknown as ServerWebSocket; + if (phase === "attached") attachLiveSidebandUpstream(downstream); + return { + data, releasedStates, finishes: () => finishes, + close() { + if (phase === "attached") closeLiveSideband(downstream, 1001, "client finished"); + else closeLiveSidebandBeforeUpgrade(upstream, release, 1001, "client finished"); + }, + async waitForRelease() { + let timer: ReturnType | undefined; + try { + await Promise.race([ + released.promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("sideband lease remained held")), 3_000); }), + ]); + } finally { clearTimeout(timer); } + }, + }; +} + +/** A real TCP peer that optionally acknowledges Close; Bun.serve always acknowledges it. */ +async function withClosePeer(answerClose: boolean, run: (fixture: { + upstream: TerminableSocket; + closes: Array<{ code: number; reason: string }>; + terminations(): number; + peerClosed: Promise; +}) => Promise) { + const peers = new Set(); + const closes: Array<{ code: number; reason: string }> = []; + const peerClosed = Promise.withResolvers(); + const server = createServer(peer => { + peers.add(peer); + peer.on("error", () => {}); + peer.on("close", () => { peers.delete(peer); peerClosed.resolve(); }); + let handshake = ""; + let opened = false; + let frames = Buffer.alloc(0); + peer.on("data", chunk => { + if (!opened) { + handshake += chunk.toString(); + if (!handshake.includes("\r\n\r\n")) return; + const key = /sec-websocket-key:\s*([^\r\n]+)/i.exec(handshake)?.[1]; + const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); + opened = true; + peer.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`); + return; + } + frames = Buffer.concat([frames, chunk]); + if (frames.length < 2) return; + const length = frames[1]! & 0x7f; + const masked = (frames[1]! & 0x80) !== 0; + const offset = masked ? 6 : 2; + // The fixture only receives a single, short Close control frame. + if ((frames[0]! & 0x0f) !== 8 || length > 125 || frames.length < offset + length) return; + const payload = Buffer.from(frames.subarray(offset, offset + length)); + if (masked) for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ frames[2 + i % 4]!; + closes.push({ code: payload.readUInt16BE(0), reason: payload.subarray(2).toString() }); + frames = Buffer.alloc(0); + if (answerClose) peer.end(Buffer.concat([Buffer.from([0x88, payload.length]), payload])); + }); + }); + let upstream: TerminableSocket | undefined; + try { + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + upstream = new WebSocket(`ws://127.0.0.1:${(server.address() as AddressInfo).port}`) as TerminableSocket; + let terminations = 0; + const terminate = upstream.terminate.bind(upstream); + upstream.terminate = () => { terminations++; terminate(); }; + await new Promise((resolve, reject) => { + upstream!.addEventListener("open", () => resolve(), { once: true }); + upstream!.addEventListener("error", () => reject(new Error("fixture websocket failed to open")), { once: true }); + }); + await run({ upstream, closes, terminations: () => terminations, peerClosed: peerClosed.promise }); + } finally { + upstream?.terminate(); + for (const peer of peers) peer.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } +} + +describe.each(["before upgrade", "attached"] as const)("live sideband close %s", phase => { + // The pinned 1.4.3 runner reports close before the peer responds. Exercise this + // transport regression under the bundled runtime as well as the ordinary suite. + test.skipIf(!Bun.semver.satisfies(Bun.version, ">=1.4.4"))("terminates a silent peer before releasing its lease", async () => { + await withClosePeer(false, async fixture => { + const owner = closingOwner(phase, fixture.upstream); + owner.close(); + expect(owner.releasedStates).toEqual([]); + expect(fixture.upstream.readyState).toBe(WebSocket.CLOSING); + await owner.waitForRelease(); + await fixture.peerClosed; + expect(fixture.terminations()).toBe(1); + expect(fixture.closes).toEqual([{ code: 1001, reason: "client finished" }]); + expect(owner.releasedStates).toEqual([WebSocket.CLOSED]); + if (phase === "attached") { + expect(owner.data.liveUpstream).toBeUndefined(); + expect(owner.data.liveCloseFallback).toBeUndefined(); + expect(owner.finishes()).toBe(1); + } + }); + }); + + test("preserves a graceful close and cancels forced termination", async () => { + await withClosePeer(true, async fixture => { + const owner = closingOwner(phase, fixture.upstream); + owner.close(); + await owner.waitForRelease(); + await fixture.peerClosed; + await Bun.sleep(1_100); + expect(fixture.closes).toEqual([{ code: 1001, reason: "client finished" }]); + expect(fixture.terminations()).toBe(0); + expect(owner.releasedStates).toEqual([WebSocket.CLOSED]); + }); + }); +}); + +test.each(["missing", "throws", "still closing", "closed"] as const)("termination %s preserves actual-close ownership", async mode => { + class Upstream extends EventTarget { + readyState: number = WebSocket.OPEN; + closes = 0; + terminations = 0; + terminate?: () => void; + close() { this.closes++; this.readyState = WebSocket.CLOSING; } + finish() { this.readyState = WebSocket.CLOSED; this.dispatchEvent(new CloseEvent("close", { code: 1000 })); } + } + const cases = (["before upgrade", "attached"] as const).map(phase => { + const upstream = new Upstream(); + if (mode !== "missing") upstream.terminate = () => { + upstream.terminations++; + if (mode === "throws") throw new Error("fixture termination failed"); + if (mode === "closed") upstream.readyState = WebSocket.CLOSED; + }; + const owner = closingOwner(phase, upstream as unknown as WebSocket); + owner.close(); + expect(owner.releasedStates).toEqual([]); + return { upstream, owner }; + }); + try { + await Bun.sleep(1_100); + for (const { upstream, owner } of cases) { + expect(upstream.closes).toBe(mode === "missing" ? 2 : 1); + expect(upstream.terminations).toBe(mode === "missing" ? 0 : 1); + expect(owner.releasedStates).toEqual(mode === "closed" ? [WebSocket.CLOSED] : []); + upstream.finish(); + upstream.finish(); + expect(owner.releasedStates).toEqual([WebSocket.CLOSED]); + } + } finally { for (const { upstream } of cases) upstream.finish(); } +}); From d28bbc21f39bd82c2374496b8ca181d07cb51425 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 21:47:58 +0900 Subject: [PATCH 6/8] test(server): isolate server-live fixture from host service-manager probes Every case in this file exercises the /v1/live relay or the readiness surface; none asserts on service-home ownership. Route startServer calls through startTestServer, which injects the established ownedServiceHomeInspection seam so in-process fixtures stop spawning sc/schtasks probes (~60 inspections, ~2.3s each on non-English hosts, was the 180s incompletion). deps spread last keeps per-case injections (readinessGate, liveSidebandWebSocketFactory) authoritative. --- tests/server/server-live.test.ts | 91 +++++++++++++++++++------------- 1 file changed, 54 insertions(+), 37 deletions(-) diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index 28c68c04a1b..5710b25b01f 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -22,6 +22,7 @@ import { MAX_WS_FRAME_BYTES, openLiveSidebandUpstream, startServer, + type StartServerDeps, } from "../../src/server"; import { activeRegistryMetrics, @@ -32,6 +33,7 @@ import { import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { phaseTimer } from "../helpers/phase-timing"; import { expectSidebandUpgrade, openSidebandClient, redirectSidebandWebSocket, sidebandRelayUpstream } from "../helpers/sideband-relay-probe"; @@ -149,6 +151,21 @@ function forwardConfig(): OcxConfig { } as OcxConfig; } +// Every case in this file exercises the /v1/live relay or the readiness surface; +// none asserts on service-home ownership. Route through the established test seam +// so the host's real service-manager probes stay out of an in-process fixture. +// deps spread last so per-case injections (readinessGate, the sideband factory) +// still win over the default. +function startTestServer( + port?: number, + deps: StartServerDeps = {}, +): ReturnType { + return startServer(port, { + inspectNativeCodexOwnership: ownedServiceHomeInspection("server-live fixture"), + ...deps, + }); +} + function expectReadyProtocolMetadata(body: Record, managementUrl: string): void { expect(body).toMatchObject({ protocol: 1, @@ -192,7 +209,7 @@ test("POST /v1/live rewrites ChatGPT multipart into backend realtime/calls JSON" config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///"; saveConfig(config); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-offer", { model: "gpt-live", instructions: "hi" }); const response = await fetch(new URL("/v1/live", server.url), { @@ -241,7 +258,7 @@ test.each(["/v1/live", "/backend-api/codex/live"])("POST %s relays to an OpenAI }, } as OcxConfig); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody(); const response = await fetch(new URL(path, server.url), { @@ -272,7 +289,7 @@ test("call-create forwards Frameless protocol headers and keeps auth pool-owned" const upstream = fakeLiveUpstream(captured); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-offer", { model: "gpt-live", instructions: "hi" }); const response = await fetch(new URL("/v1/live", server.url), { @@ -320,7 +337,7 @@ test("call-create without protocol headers does not invent them upstream", async const upstream = fakeLiveUpstream(captured); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-offer", { model: "gpt-live", instructions: "hi" }); const response = await fetch(new URL("/v1/live", server.url), { @@ -353,7 +370,7 @@ test("POST /v1/realtime/calls is accepted and relays like /v1/live", async () => const upstream = fakeLiveUpstream(captured, 201, "/v1/realtime/calls/rtc_codex"); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-codex"); const response = await fetch(new URL("/v1/realtime/calls", server.url), { @@ -384,7 +401,7 @@ test("ChatGPT multipart rewrite allows SDP-only offers without session", async ( const upstream = fakeLiveUpstream(captured); saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody("v=0-sdp-only", null); const response = await fetch(new URL("/v1/live", server.url), { @@ -407,7 +424,7 @@ test("ChatGPT multipart rewrite allows SDP-only offers without session", async ( test("OPTIONS preflight allows ChatGPT-Account-Id for voice clients", async () => { saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const response = await fetch(new URL("/v1/live", server.url), { method: "OPTIONS", @@ -440,7 +457,7 @@ test("POST /v1/live without an OpenAI upstream returns 400", async () => { }, } as OcxConfig); - const server = startServer(0); + const server = startTestServer(0); try { const response = await fetch(new URL("/v1/live", server.url), { method: "POST", @@ -457,7 +474,7 @@ test("POST /v1/live without an OpenAI upstream returns 400", async () => { test("GET /v1/live still hits the unknown-endpoint JSON 404 guard", async () => { saveConfig(forwardConfig()); - const server = startServer(0); + const server = startTestServer(0); try { const response = await fetch(new URL("/v1/live", server.url), { method: "GET", @@ -500,7 +517,7 @@ test("a routed pool account's token overrides the caller bearer on the live rela chatgptAccountId: "acct-pool-a", }); - const server = startServer(0); + const server = startTestServer(0); try { const { body, contentType } = multipartLiveBody(); const response = await fetch(new URL("/v1/live", server.url), { @@ -579,7 +596,7 @@ test("call-create and its sideband join bind to the same pool account (openai/co } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const voiceHeaders = { authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}`, @@ -671,7 +688,7 @@ test("sideband GET /v1/live/{callId} relays the exact frame ceiling bidirectiona // helper because this case is at its size cap and the repo answer is not to compress. const { OriginalWebSocket, restore } = redirectSidebandWebSocket(upstream.port); restoreWebSocket = restore; - const server = live = startServer(0); + const server = live = startTestServer(0); const ws = openSidebandClient(OriginalWebSocket, server.url, "/v1/live/rtc_sideband", DIRECT_CHATGPT_TOKEN); client = ws; // Each leg is its own segment and the timer's ticks read the peer's event count, so a @@ -764,7 +781,7 @@ test("standalone GET /v1/realtime?intent=quicksilver&model= upgrades and relays } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/realtime?intent=quicksilver&model=gpt-realtime-1.5&access_token=dropme`, server.url); wsUrl.protocol = "ws:"; @@ -844,7 +861,7 @@ test("standalone GET /v1/live?model= upgrades and relays bidirectionally", async } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/live?model=gpt-realtime-1.5`, server.url); wsUrl.protocol = "ws:"; @@ -888,7 +905,7 @@ test("standalone realtime upgrade keeps the auth and origin guards", async () => saveConfig(forwardConfig()); process.env.OPENCODEX_API_AUTH_TOKEN = "required-secret"; - const server = startServer(0); + const server = startTestServer(0); try { const upgradeHeaders = { connection: "upgrade", @@ -1211,7 +1228,7 @@ test("sideband relay preserves multibyte UTF-8 frames byte-identically in both d } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/live/rtc_korean`, server.url); wsUrl.protocol = "ws:"; @@ -1315,7 +1332,7 @@ test("sideband frame log preserves delivery without recording damaged or clean t } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); try { const wsUrl = new URL(`/v1/live/rtc_framelog`, server.url); wsUrl.protocol = "ws:"; @@ -1396,7 +1413,7 @@ describe("GET /readyz", () => { for (const { outcome, expectedStatus, expectedHttp } of cases) { const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const pending = await fetch(new URL("/readyz", server.url)); expect(pending.status).toBe(503); @@ -1420,7 +1437,7 @@ describe("GET /readyz", () => { test("fresh gate is pending; /healthz 200 while /readyz 503 with Retry-After", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const base = server.url; // Liveness is immediate and unchanged. @@ -1459,7 +1476,7 @@ describe("GET /readyz", () => { runtimeRole: "hub", hub: { managementPublicOrigin: "https://hub.example.test" }, }); - const server = startServer(0); + const server = startTestServer(0); try { const ready = await fetch(new URL("/readyz", server.url), { headers: { @@ -1484,7 +1501,7 @@ describe("GET /readyz", () => { test("/readyz is 200 with status ready only after gate.markReady()", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const base = server.url; expect(((await (await fetch(new URL("/readyz", base))).json()) as { status: string }).status).toBe("pending"); @@ -1504,7 +1521,7 @@ describe("GET /readyz", () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); gate.markFailed(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const res = await fetch(new URL("/readyz", server.url)); expect(res.status).toBe(503); @@ -1518,7 +1535,7 @@ describe("GET /readyz", () => { test("exact method/path: POST /readyz and GET /readyz/ are NOT matched", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const base = server.url; // POST must not be accepted as readiness (falls through to the JSON 404 guard). @@ -1560,7 +1577,7 @@ describe("GET /readyz", () => { test("/readyz answers without an admission token (unauthenticated, like /healthz)", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { // No Authorization header, no API auth token in env (beforeEach deletes it). const res = await fetch(new URL("/readyz", server.url), { headers: { Authorization: "" } }); @@ -1576,7 +1593,7 @@ describe("GET /readyz", () => { test("ephemeral startServer(0): /readyz reports the actual bound port (not the requested 0)", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { const boundPort = new URL(server.url).port; // The bound port is a real ephemeral port, not the requested 0. @@ -1601,8 +1618,8 @@ describe("per-server readiness gate isolation", () => { saveConfig(forwardConfig()); const gateA: ReadinessGate = createReadinessGate(); const gateB: ReadinessGate = createReadinessGate(); - const serverA = startServer(0, { readinessGate: gateA }); - const serverB = startServer(0, { readinessGate: gateB }); + const serverA = startTestServer(0, { readinessGate: gateA }); + const serverB = startTestServer(0, { readinessGate: gateB }); try { gateA.markReady(); // gateB stays pending. @@ -1624,8 +1641,8 @@ describe("per-server readiness gate isolation", () => { saveConfig(forwardConfig()); const gateA: ReadinessGate = createReadinessGate(); const gateB: ReadinessGate = createReadinessGate(); - const serverA = startServer(0, { readinessGate: gateA }); - const serverB = startServer(0, { readinessGate: gateB }); + const serverA = startTestServer(0, { readinessGate: gateA }); + const serverB = startTestServer(0, { readinessGate: gateB }); try { gateA.markReady(); gateB.markFailed(); @@ -1646,7 +1663,7 @@ describe("per-server readiness gate isolation", () => { test("a server with no gate passed in still serves pending /readyz (fresh private gate)", async () => { saveConfig(forwardConfig()); // No gate supplied → startServer creates a fresh pending private gate. - const server = startServer(0); + const server = startTestServer(0); try { const res = await fetch(new URL("/readyz", server.url)); expect(res.status).toBe(503); @@ -1661,7 +1678,7 @@ describe("per-server readiness gate isolation", () => { const gateA: ReadinessGate = createReadinessGate(); const gateB: ReadinessGate = createReadinessGate(); // Ephemeral start: A binds a real kernel-assigned port (NOT a fixed user port). - const serverA = startServer(0, { readinessGate: gateA }); + const serverA = startTestServer(0, { readinessGate: gateA }); try { gateA.markReady(); const occupiedPort = Number(new URL(serverA.url).port); @@ -1672,7 +1689,7 @@ describe("per-server readiness gate isolation", () => { // Binding the occupied port with a separate gate must throw (EADDRINUSE). // It must never silently share A's listener or reset A's gate. - expect(() => startServer(occupiedPort, { readinessGate: gateB })).toThrow(); + expect(() => startTestServer(occupiedPort, { readinessGate: gateB })).toThrow(); // A is unaffected by the failed bind: still HTTP 200 / ready. const resA = await fetch(new URL("/readyz", serverA.url)); @@ -1702,7 +1719,7 @@ describe("GET /readyz while draining", () => { test("a ready gate reports pending (503) once the listener starts draining", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); - const server = startServer(0, { readinessGate: gate }); + const server = startTestServer(0, { readinessGate: gate }); try { gate.markReady(); const base = server.url; @@ -2024,7 +2041,7 @@ test("a failed pre-upgrade handshake retains admission until its CLOSING upstrea saveConfig(forwardConfig()); const upstream = new FakeUpstreamSocket(); upstream.closeMode = "closing"; - const server = startServer(0, { + const server = startTestServer(0, { liveSidebandWebSocketFactory: () => { queueMicrotask(() => upstream.emit("error", {})); return upstream as unknown as WebSocket; @@ -2068,7 +2085,7 @@ test("a failed pre-upgrade handoff retains admission until its CLOSING upstream saveConfig(forwardConfig()); const upstream = new FakeUpstreamSocket(); upstream.closeMode = "closing"; - const server = startServer(0, { + const server = startTestServer(0, { liveSidebandWebSocketFactory: () => { queueMicrotask(() => { upstream.emit("open", {}); @@ -2141,7 +2158,7 @@ test("a sideband join whose upstream handshake fails never opens the client sock } } as typeof WebSocket; - const server = startServer(0); + const server = startTestServer(0); const activeTurnsBefore = activeRegistryMetrics().activeTurns.active; try { const wsUrl = new URL(`/v1/realtime?call_id=rtc_dead_call`, server.url); @@ -2190,7 +2207,7 @@ test("a sideband join whose upstream handshake fails never opens the client sock test("an upstream that opens then closes before relay attachment refuses the client and releases admission", async () => { saveConfig(forwardConfig()); const upstream = new FakeUpstreamSocket(); - const server = startServer(0, { + const server = startTestServer(0, { liveSidebandWebSocketFactory: () => { queueMicrotask(() => { upstream.emit("open", {}); From e8ae44f207519dda022b1849d17feb8b8108bb13 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 22:01:46 +0900 Subject: [PATCH 7/8] test(live): compact ownership fixture wrapper comments and signature --- tests/server/server-live.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index 5710b25b01f..cc95a52e006 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -151,15 +151,9 @@ function forwardConfig(): OcxConfig { } as OcxConfig; } -// Every case in this file exercises the /v1/live relay or the readiness surface; -// none asserts on service-home ownership. Route through the established test seam -// so the host's real service-manager probes stay out of an in-process fixture. -// deps spread last so per-case injections (readinessGate, the sideband factory) -// still win over the default. -function startTestServer( - port?: number, - deps: StartServerDeps = {}, -): ReturnType { +// Isolate relay/readiness fixtures from host service-manager probes. +// Explicit per-case dependencies override this default. +function startTestServer(port?: number, deps: StartServerDeps = {}): ReturnType { return startServer(port, { inspectNativeCodexOwnership: ownedServiceHomeInspection("server-live fixture"), ...deps, From e07e4ec138caae8288d32760ee329f1a81f7982c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 03:58:52 +0900 Subject: [PATCH 8/8] test(codex): pin pool join-key format; fix Bun pin comment - codex-stored-main-duplicate: assert the changed pool row's upstreamAccountKey matches /^uai1_[0-9a-f]{64}$/ before the inequality check (CodeRabbit 4366948593) - setup-project-bun: both package pins are 1.4.4 and the runtime / test-runner roles stay independently validated; role.default remains runtime (CodeRabbit 4367227912) Addresses review comments on #7635 at e8ae44f2. --- .github/actions/setup-project-bun/action.yml | 2 +- tests/codex-integration/codex-stored-main-duplicate.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index cf28f6f9d43..7829f86f7e9 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -8,7 +8,7 @@ description: >- # (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to # 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after # finalizers ran inside an earlier call and spin past its own timeout (#4956). -# Both roles now default to the package's 1.4.4 security transition, retaining role validation. +# Both package pins are 1.4.4; runtime and test-runner roles remain independently validated. # Promotion requires a green full cross-platform lane=all run on the exact candidate head. inputs: role: diff --git a/tests/codex-integration/codex-stored-main-duplicate.test.ts b/tests/codex-integration/codex-stored-main-duplicate.test.ts index daba4f87eaf..765d27957fb 100644 --- a/tests/codex-integration/codex-stored-main-duplicate.test.ts +++ b/tests/codex-integration/codex-stored-main-duplicate.test.ts @@ -104,6 +104,7 @@ describe("stored Pool duplicate of native main", () => { saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "other-user"), refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); const changed = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).toMatch(/^uai1_[0-9a-f]{64}$/); expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).not.toBe(main.upstreamAccountKey); expect(changed.find(row => row.id === "__main__")?.upstreamAccountKey).toBe(main.upstreamAccountKey); });