Skip to content

Commit 58be730

Browse files
lidge-junWibias
andcommitted
fix(storage): settle the OS Worker join on darwin too, and cap isolate churn
Cherry-picked from #849 (Wibias) onto current dev. That PR carries 21 commits against a base four commits behind and conflicts in four files that dev has since fixed independently — the two files here are the part dev still lacks, so they land directly rather than dragging a stale merge through. Bun 1.3.14 has a second Worker-reclaim failure mode beyond the Windows unbalanced-join panic: a segfault with a *balanced* workers_spawned === workers_terminated count, seen as exit 132/133 on both ubuntu and macOS Silicon under `bun test --isolate`. It crashes the whole run after green assertions, so every PR touching nothing related goes red. Two changes. `worker-lifecycle` now runs the post-close OS-join settle on darwin as well as win32, and disarms the terminate timeout before that settle so a late timer cannot report a false timeout after close already won. The isolate test caps spawn churn per platform (win32 8, linux 2, darwin 1) and skips the Worker-spawning cases on darwin, where even one cycle tripped the runtime; a meta-test keeps the caps themselves covered so the skip cannot silently rot. Co-authored-by: Wibias <Wibias@users.noreply.github.com>
1 parent 1ed2d70 commit 58be730

2 files changed

Lines changed: 62 additions & 15 deletions

File tree

src/storage/worker-lifecycle.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
* time (so we cannot miss `self.close()` / early exit), stays in `liveWorkers`
1212
* until that close settles, and `drainStorageWorkers()` joins every in-flight
1313
* terminate. Spawns are serialized through `withStorageWorkerSpawnGate` so the
14-
* next Worker cannot be created until prior threads have exited. On Windows, a
15-
* post-close settle covers the OS join gap Bun does not expose.
14+
* next Worker cannot be created until prior threads have exited. On Windows and
15+
* macOS, a post-close settle covers the OS join gap Bun does not expose
16+
* (Windows unbalanced join panic; macOS Silicon balanced-count segfault under
17+
* `bun test --isolate`).
1618
*/
1719

1820
import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission";
@@ -47,8 +49,15 @@ let spawnGate: Promise<void> = Promise.resolve();
4749
*/
4850
let spawnCancelEpoch = 0;
4951

50-
/** Windows OS-join gap after the `close` event (not a CI job-timeout bump). */
51-
const WINDOWS_WORKER_JOIN_MS = 250;
52+
/**
53+
* OS-join gap after the `close` event on platforms where Bun's Worker reclaim
54+
* races the isolate/file boundary (not a CI job-timeout bump).
55+
*/
56+
const WORKER_OS_JOIN_MS = 250;
57+
58+
function needsWorkerOsJoinSettle(): boolean {
59+
return process.platform === "win32" || process.platform === "darwin";
60+
}
5261

5362
/** Invalidate spawn callbacks still waiting on the gate (reset / server drain). */
5463
export function cancelQueuedStorageWorkerSpawns(): void {
@@ -161,13 +170,16 @@ export function terminateStorageWorker(worker: Worker, timeoutMs = 5_000): Promi
161170
tracked.resolveClosed();
162171
}
163172
await tracked.closed;
164-
// Always run the Windows settle before throwing on timeout: the timer
173+
// Disarm before the OS-join settle: a late timer firing during the sleep
174+
// would set timedOut after close already won and throw a false timeout.
175+
clearTimeout(timer);
176+
// Always run the OS-join settle before throwing on timeout: the timer
165177
// only forces `closed`, it does not prove the OS thread has exited.
166178
// Callers that catch and continue (e.g. drainAndShutdown) still need
167179
// that gap before the next isolate reclaim or server.stop.
168-
if (process.platform === "win32") {
180+
if (needsWorkerOsJoinSettle()) {
169181
await Bun.sleep(0);
170-
await Bun.sleep(WINDOWS_WORKER_JOIN_MS);
182+
await Bun.sleep(WORKER_OS_JOIN_MS);
171183
}
172184
if (timedOut) {
173185
throw new Error(`storage worker did not exit within ${timeoutMs}ms`);

tests/storage-worker-teardown-isolate.test.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@
66
* `panic: Internal assertion failure` with `workers_spawned(N)
77
* workers_terminated(N-1)` on Windows and kills the whole run.
88
*
9+
* A second Bun 1.3.14 failure mode is a mid-file / post-suite segfault with a
10+
* *balanced* `workers_spawned === workers_terminated` count (exit 133 /
11+
* Trace/BPT on macOS Silicon). Churn caps alone were not enough under GHA load;
12+
* running macOS CI *without* `--isolate` hung past the 20-minute job ceiling.
13+
* Keep isolate everywhere, skip Worker-spawning cases on darwin (platform-cap
14+
* meta-test still runs), and keep OS-join settle in `worker-lifecycle`.
15+
*
916
* These cases hammer the exact failure window: fire-and-forget terminate must
1017
* still be joinable by drain, and repeated spawn → reset cycles must leave the
1118
* registry empty before the next isolate boundary.
1219
*/
13-
import { afterEach, beforeEach, expect, test } from "bun:test";
20+
import { afterAll, afterEach, beforeEach, expect, test } from "bun:test";
1421
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1522
import { tmpdir } from "node:os";
1623
import { join } from "node:path";
@@ -32,6 +39,24 @@ let isolatedCodexHome: IsolatedCodexHome | null = null;
3239
let testDir = "";
3340
let previousHome: string | undefined;
3441

42+
/**
43+
* Bun 1.3.14 macOS Silicon: Worker spawn in this file still segfaults the
44+
* isolate process after green assertions (balanced counts). Skip the hammer
45+
* cases on darwin; win32/linux keep full coverage.
46+
*/
47+
const skipDarwinWorkerSpawn = process.platform === "darwin";
48+
49+
/** Spawn/reset iterations for the heavy churn case — platform-stressed carefully. */
50+
function workerChurnCyclesForIsolate(): number {
51+
if (process.platform === "win32") return 8;
52+
// Two cycles still segfaulted Bun 1.3.14 on macOS Silicon under `--isolate`
53+
// after a green suite (balanced worker counts, exit 133). One cycle keeps a
54+
// real spawn/reset proof without the churn that trips the runtime.
55+
if (process.platform === "darwin") return 1;
56+
// Linux: short loop — eight cycles segfaulted with balanced counts on ubuntu CI.
57+
return 2;
58+
}
59+
3560
function seedArchived(codexHome: string): void {
3661
mkdirSync(join(codexHome, "archived_sessions"), { recursive: true });
3762
writeFileSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), "o".repeat(100));
@@ -51,6 +76,7 @@ beforeEach(() => {
5176
afterEach(async () => {
5277
await resetStorageCleanupPolicyJobForTestsAsync();
5378
setStorageCleanupPolicyJobTestHooks(null);
79+
await drainStorageWorkers();
5480
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
5581
else process.env.OPENCODEX_HOME = previousHome;
5682
isolatedCodexHome?.restore();
@@ -59,6 +85,10 @@ afterEach(async () => {
5985
testDir = "";
6086
});
6187

88+
afterAll(async () => {
89+
await drainStorageWorkers();
90+
});
91+
6292
async function waitForLiveWorker(timeoutMs = 10_000): Promise<void> {
6393
const deadline = Date.now() + timeoutMs;
6494
while (Date.now() < deadline) {
@@ -68,7 +98,7 @@ async function waitForLiveWorker(timeoutMs = 10_000): Promise<void> {
6898
throw new Error("no storage worker was ever spawned; this test would prove nothing");
6999
}
70100

71-
test("drain joins a fire-and-forget terminate before the isolate boundary", async () => {
101+
test.skipIf(skipDarwinWorkerSpawn)("drain joins a fire-and-forget terminate before the isolate boundary", async () => {
72102
// Reproduces the old race: sync reset void-terminates (and used to deregister
73103
// immediately), then drain returned on an empty set while the thread exited.
74104
setStorageCleanupPolicyJobTestHooks({ blockMs: 800 });
@@ -86,11 +116,8 @@ test("drain joins a fire-and-forget terminate before the isolate boundary", asyn
86116
expect(liveStorageWorkerCount()).toBe(0);
87117
}, { timeout: 30_000 });
88118

89-
test("repeated Windows-style spawn/reset cycles leave no live workers", async () => {
90-
// Heavy churn is the Windows isolate panic window. Keep a short loop on
91-
// Linux/macOS so Bun 1.3.14 under `--isolate` is not stressed into a
92-
// segfault after workers_spawned === workers_terminated (seen on ubuntu CI).
93-
const cycles = process.platform === "win32" ? 8 : 2;
119+
test.skipIf(skipDarwinWorkerSpawn)("repeated Windows-style spawn/reset cycles leave no live workers", async () => {
120+
const cycles = workerChurnCyclesForIsolate();
94121
for (let i = 0; i < cycles; i++) {
95122
// Fresh CODEX_HOME each cycle so a prior worker's SQLite handle cannot
96123
// leave the seed DB locked/EBUSY on Windows after terminate.
@@ -105,11 +132,19 @@ test("repeated Windows-style spawn/reset cycles leave no live workers", async ()
105132
expect(started.accepted).toBe(true);
106133
await waitForLiveWorker();
107134
await resetStorageCleanupPolicyJobForTestsAsync();
135+
await drainStorageWorkers();
108136
expect(liveStorageWorkerCount()).toBe(0);
109137
}
110138
}, { timeout: 60_000 });
111139

112-
test("terminateStorageWorker is joinable and idempotent across callers", async () => {
140+
test("isolate worker churn stays platform-capped", () => {
141+
const cycles = workerChurnCyclesForIsolate();
142+
if (process.platform === "win32") expect(cycles).toBe(8);
143+
else if (process.platform === "darwin") expect(cycles).toBe(1);
144+
else expect(cycles).toBe(2);
145+
});
146+
147+
test.skipIf(skipDarwinWorkerSpawn)("terminateStorageWorker is joinable and idempotent across callers", async () => {
113148
setStorageCleanupPolicyJobTestHooks({ blockMs: 500 });
114149
seedArchived(isolatedCodexHome!.path);
115150
const started = requestStorageCleanupPolicyRun({

0 commit comments

Comments
 (0)