Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/actions/setup-project-bun/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ description: >-
runtime (default), or testRunnerBun for test-runner.

# Runtime 1.4.2 fixes #6684's Windows Bun.fetch stream truncation. The test runner
# stays on 1.4.0: #4821 reverted #4064 after 1.4.2's multi-file --isolate preload
# segfault; #6713 run 37631558263 reproduces it on Linux 1/4, macOS 2/2, Windows 9/9.
# skipped 1.4.2: #4821 reverted #4064 after its multi-file --isolate preload segfault
# (#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.
inputs:
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,9 @@ jobs:
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;
# test-runner 1.4.0 avoids 1.4.2's multi-file --isolate preload segfault (#4821
# reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9).
# 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.
# The suite, split by file across four Linux runners.
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ 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.0 (`testRunnerBun`) while the shipped runtime is 1.4.2.
`scripts/test.ts` finds a 1.4.0 binary on PATH or in `~/.bun/bin`, or uses
The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.2.
`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses
`OCX_TEST_RUNNER_BUN`; install that version locally if it is missing.

Run `bun run test` before review readiness. If the full local suite is too costly
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# syntax=docker/dockerfile:1

# Keep the runtime aligned with package.json and pin the multi-platform image index.
# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests stay on 1.4.0
# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; 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). Rejoin after the
# #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.
ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895
Expand Down
27 changes: 27 additions & 0 deletions devlog/_plan/261012_4956_spawnsync_exit_stall/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# #4956 — Bun 1.4.0 spawnSync loses a child exit inside multi-file `bun test --isolate`

## Evidence

- All three hosted stalls of Linux shard 1/4 batch 5/57 (run 38162375606 attempts 1 and 2, job 114537864404 / 114545178864; PR run 38146205183 job 114490736681) stop at the same test: `tests/ci-workflows/release-version-sources.test.ts` "the check CLI fails naming the drifted source and passes when they agree". Five tests of that file printed; its sixth never did and no summary line followed. The issue comment's "every test passed, then Bun failed to exit" misreads the log.
- That test calls `Bun.spawnSync([bun, cli], { timeout: 15000, killSignal: "SIGKILL" })` several times. About 112 s passed between the last printed result and the 120 s batch kill, far beyond the 15 s bound, so the child-process timeout did not return. The hosted log alone does not say which call stalled; the scratch reproduction below does.
- Bun 1.4.0 (34cbb9a40) `spawn_maybe_sync` waits on a private uWS loop and, for the call's duration, repoints `vm.event_loop_handle` at it. A GC finalizer that runs during the call (an earlier file's `process.stderr` sink, a pipe reader, a `KeepAlive`) resolves its loop through the VM and decrements the private loop's `num_polls`. That loop is cached for the life of the process. Once the drift cancels a later call's polls, `us_loop_run_bun_tick` returns without calling `epoll_wait`/`kevent`, the child's exit poll is never read, and after the timeout fires the following ticks carry no deadline: the call spins at 100% CPU forever. Upstream: oven-sh/bun#44581 (merged 2026-10-05, `13a98b0dbd`, fixes oven-sh/bun#34069), first released in Bun v1.4.3 (2026-10-10).
- Local reproduction (macOS arm64) with upstream's fixture, one finalized `Bun.stderr` writer, stderr piped, `BUN_JSC_slowPathAllocsBetweenGCs=3`, inner spawnSync timeout 2 s, outer 6 s alarm: Bun 1.4.0 hung past its own timeout in 10/12 runs at 100% CPU; Bun 1.4.3 returned the child's output in 12/12 (~115 ms). With three writers 1.4.0 returns only at the timeout with empty output (3/3), 1.4.3 returns output (3/3).
- Composition dependence: in the suite the finalized objects come from earlier files in the same process (`--isolate` retires realms but keeps the VM, the FilePoll hive and the cached private loop), which can explain why each file alone passes and attribution sweeps stay clean. The mechanism itself does not need other files: a single script reproduces it.
- Hosted reproduction (scratch branch `codex/4956-diag-repro`, not for merge): 450 isolated runs of the 12-file batch never stalled (run 38213203939), but running shard 1/4 batches 1-5 in sequence like CI did, on Bun 1.4.0 (run 38213764818): 8 of 252 batch-5 runs stalled at the same test. Every dump shows the test process main thread in state R with no wait channel, symbolized as `spawn_maybe_sync` (js_bun_spawn_bindings.rs:1982/1956) -> `tick_with_timeout` -> `tick_tasks_only`; the CLI child is a zombie whose parent is the test process; its pidfd is registered level-triggered (`events: 19`) in spawnSync's private epoll and readable, yet never waited on. That state is consistent with the `num_polls == 0` early return #44581 removes (the dumps show the loop never waiting, not the counter itself). Hosted 1.4.3 A/B: run 38215466714.

## Change

1. `package.json` `testRunnerBun`: 1.4.0 → 1.4.3. The shipped runtime (`dependencies.bun` 1.4.2) is unchanged; unifying it is a product decision outside this fix.
2. Regression test `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` + child `tests/fixtures/spawnsync-finalizer-drift-child.ts`: runs upstream's mechanism in a child of the test-runner Bun with stderr piped, for one and three finalized writers, and asserts the child saw every writer collected during the first call and that the second call returned the grandchild's output without timing out. The parent never blocks: a 10 s deadline kills the child, a 3 s reap grace follows, and the case settles whether or not the exit or either pipe EOF arrives (unref on expiry, kill failure ignored, timers cleared on every path). Three writers is the deterministic red oracle on 1.4.0; one writer exercises the unbounded spin. Skipped on Windows like upstream's case (POSIX mechanism, uses `sh`/`true`).
3. `tests/ci-workflows/install-scripts.test.ts` asserts the exact pin (`testRunnerBun` 1.4.0 -> 1.4.3) and its comment keeps the 1.4.2 history and adds this reason.
4. Rationale text where the pin is explained: `.github/actions/setup-project-bun/action.yml`, `.github/workflows/ci.yml` Bun-roles comment, `structure/runtime.md`, `CONTRIBUTING.md`, `docs-site/src/content/docs/contributing.md`. Injected 1.4.0 fixture values in `test-runner-bun.test.ts` and `ci-scope-gaps.test.ts` and transport minimums stay as they are.
5. Layout registration in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`.

## Risks and gates

- 1.4.2's multi-file `--isolate` preload segfault (#4821, #6713) is the reason for the pin. 1.4.3 must show no runtime crash across Cross-platform CI `lane=all` (Linux 4 shards, macOS 2, Windows 9, serial families) at the exact PR head. If it reappears, this patch is abandoned and re-planned: the runner pin, the regression contract (which correctly fails on 1.4.0) and a workaround proof (async spawn with a bounded kill at the spawnSync-with-timeout fixtures) move together. Restoring 1.4.0 while skipping or weakening the regression is not an acceptable fallback.
- Version-coupled tests (`tests/test-layout-tooling.test.ts` asserts `Bun.version === testRunnerBun`) follow automatically.

## Verification

`bun run typecheck`; focused tests under 1.4.3 via `OCX_TEST_RUNNER_BUN` (new regression red on 1.4.0 / green on 1.4.3, 20 fresh children per case; install-scripts, test-runner-bun, ci-scope-gaps, docs-bun-source-requirement, both layout guards, cold-spawn-warmup); bounded-settlement fault cases (never-exiting child, grandchild holding pipes) in scratch; the 12-file batch repeated under 1.4.3; hosted 1.4.3 sequence A/B; `bun run test` under 1.4.3; `bun run structure:check`; `bun run privacy:scan`; exact-head `lane=all` inspected per batch for preload crashes and missing summaries. Workflow/dependency-pin surfaces need the maintainer security review named in MAINTAINERS.md before merge.
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ bun run privacy:scan # credential/privacy scan used by CI
bun run prepare:package # refresh package launchers/assets
```

The suite uses Bun 1.4.0 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.2.
`scripts/test.ts` finds a 1.4.0 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`.
The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.2.
`scripts/test.ts` finds a 1.4.3 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.

Run `bun run test` by default. If a full run is disproportionately expensive for the task size,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"trustedDependencies": [
"bun"
],
"testRunnerBun": "1.4.0",
"testRunnerBun": "1.4.3",
"scripts": {
"dev": "bun run src/cli/index.ts start",
"dev:proxy": "bun run src/cli/index.ts start",
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1817,7 +1817,7 @@
"terminal-guard-server.test.ts": "server",
"terminal-guard.test.ts": "server",
"test-home-guard.test.ts": "ci-workflows",
"test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows",
"test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows",
"thought-signature-credential-scope.test.ts": "responses",
"token-estimate.test.ts": "lib",
"token-guardian.test.ts": "codex-integration",
Expand Down
21 changes: 20 additions & 1 deletion src/claude/intercept/picker-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,18 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
} else req.destroy();
}
};
// An HTTP/1.1 reply sent while the upload is unfinished advertises Connection: close. Bun 1.4.3
// ends such a response with socket.destroySoon() before our finish listeners run, which resets
// the splice before the reply reaches the client; keep it a graceful FIN that discards input.
// Only that closing socket is changed, and destroy() stays available for errors and shutdown.
const closesUnfinishedUpload = () => !h2 && uploading && !req.complete;
const keepClosingReplyIntact = () => {
const socket = req.socket;
socket.destroySoon = () => { req.resume(); socket.end(); return socket; };
};
// Refusals answer with an empty response and a fixed log line that carries no request data.
const refuse = (status: 400 | 503) => {
if (closesUnfinishedUpload()) keepClosingReplyIntact();
// Do not leave a refused, still-uploading stream behind after delivering its empty reply.
if (uploading && !req.complete) {
req.once("error", () => res.destroy());
Expand Down Expand Up @@ -236,6 +246,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
if (clientGone) return;
if (res.headersSent) res.destroy();
else {
if (closesUnfinishedUpload()) keepClosingReplyIntact();
res.writeHead(502, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) });
res.end(); log(502);
}
Expand All @@ -260,7 +271,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
// HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
else {
if (uploading && !req.complete) raw.push("Connection", "close");
if (uploading && !req.complete) {
keepClosingReplyIntact();
raw.push("Connection", "close");
}
res.writeHead(status, upRes.statusMessage, raw);
}
log(status);
Expand Down Expand Up @@ -438,6 +452,8 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
const offersH2 = clientHelloOffersH2(head);
if (offersH2 === null) return;
client.off("data", onData);
// Keep reading an aborted upload after the backend reply FIN.
client.allowHalfOpen = true;
// Paused, a FIN or later bytes wait in the readable buffer and follow `head` once piped.
client.pause();
clearTimeout(deadline);
Expand All @@ -450,6 +466,9 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
// its peer (reset, dial failure) tears the peer down instead of leaving it open.
inner.once("close", () => { spliced.delete(inner); if (!client.writableEnded) client.destroy(); });
client.once("close", () => { if (!inner.writableEnded) inner.destroy(); });
// The backend reply ended; discard ciphertext still coming from the aborted upload.
// Unpipe before resuming, so backpressure cannot hide the peer FIN.
inner.once("end", () => { client.unpipe(inner); client.resume(); });
inner.once("connect", () => {
inner.setNoDelay(true);
client.setNoDelay(true);
Expand Down
16 changes: 13 additions & 3 deletions src/codex/prompt-lock-claim.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { ownEvidence, ownerState, safeNamespace, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
import { ownEvidence, ownerState, safeNamespace, safeNamespaceStat, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
import { ChangedLock as ChangedClaim, checkEntry, checkIdentity, guarded, type Entry, type Namespace } from "./prompt-lock-evidence";
import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";

Expand Down Expand Up @@ -41,8 +41,18 @@ export function withLockClaim<T>(
if (error instanceof LockFileBusy) return { ok: false };
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
if (!safeNamespace(directory, "directory", deps)) throw new UnsafeLockNamespace(directory);
const parents = [root, { path: directory, stat: io(() => deps.lstat(directory)) }];
// A peer's cleanup removes the empty claims directory, so it can vanish between our mkdir and
// this read. That is contention, not an unsafe namespace and not a crash: report it as busy.
// One observation is both validated and kept as the parent witness, so a replacement that
// appears after a missing read can never be trusted without its own ownership check.
let directoryStat: ReturnType<OwnerDeps["lstat"]>;
try { directoryStat = io(() => deps.lstat(directory)); }
catch (error) {
if (error instanceof LockFileBusy || (error as NodeJS.ErrnoException).code === "ENOENT") return { ok: false };
throw error;
}
if (!safeNamespaceStat(directoryStat, "directory", deps)) throw new UnsafeLockNamespace(directory);
const parents = [root, { path: directory, stat: directoryStat }];
const claimDirectoryGone = (): boolean => {
try { const now = deps.lstat(directory); return now.dev !== parents[1].stat.dev || now.ino !== parents[1].stat.ino; }
catch { return true; }
Expand Down
12 changes: 8 additions & 4 deletions src/codex/prompt-lock-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,16 @@ export function ownerState(record: OwnerEvidence | null, deps: OwnerDeps): "dead
return deps.isProcessAlive(record.pid) === false ? "dead" : "live";
} catch { return "live"; }
}
/** Type, no symlink, and current-user ownership on POSIX, judged on one captured observation. */
export function safeNamespaceStat(stat: LockStat, kind: "file" | "directory", deps: OwnerDeps): boolean {
const uid = deps.uid();
return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
&& (deps.platform === "win32" || (uid !== undefined
&& stat.uid === (typeof stat.uid === "bigint" ? BigInt(uid) : uid)));
}
export function safeNamespace(path: string, kind: "file" | "directory", deps: OwnerDeps, missing = false): boolean {
try {
const stat = lockFileOperation(() => deps.lstat(path), deps.platform), uid = deps.uid();
return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
&& (deps.platform === "win32" || (uid !== undefined
&& stat.uid === (typeof stat.uid === "bigint" ? BigInt(uid) : uid)));
return safeNamespaceStat(lockFileOperation(() => deps.lstat(path), deps.platform), kind, deps);
} catch (error) {
if (error instanceof LockFileBusy) throw error;
return missing && (error as NodeJS.ErrnoException).code === "ENOENT";
Expand Down
9 changes: 8 additions & 1 deletion structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,14 @@ HTTP/1.1 refusals, generated 502s and early replies advertise `Connection: close
the listener unpipes the upstream upload, reads and discards remaining input, and ends the socket
gracefully so queued response bytes flush. Errors and premature downstream closes still destroy
the request. Draining adds no upload deadline or byte cap and does not wait for request-body
completion before ending the response side. Body completion removes the input-cleanup listeners,
completion before ending the response side. Bun 1.4.3's HTTP server ends a `Connection: close`
response with `socket.destroySoon()` before the listener's own finish handler runs, which reset
the splice before the reply reached the client; every HTTP/1.1 reply that closes unfinished input
(refusal, generated 502, early upstream reply) therefore replaces that socket's `destroySoon` with
the same resume-and-`end()`. The TCP front
enables half-open reads once a ClientHello is recognized and, when the backend side ends, unpipes
and keeps discarding client ciphertext, so a client still writing an abandoned upload reads the
reply and FIN and can close (Bun 1.4.2 otherwise left it open). Body completion removes the input-cleanup listeners,
leaving long-lived responses and SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on generated 502s and can reuse the same client socket.
`tests/claude-integration/claude-picker-upload.test.ts` covers headers-only refusals on both
protocols, byte-exact early replies under backpressure, completed uploads with SSE, cancellation,
Expand Down
Loading
Loading