Skip to content

Commit 1a46299

Browse files
committed
fix(storage): wait for storage workers to exit before the test file boundary
Windows CI died with `panic(...): Internal assertion failure` and no `(fail)` line — four runs across `dev`, #771, #780 and #781. The crash header names the shape: `workers_spawned(9) workers_terminated(8)`, one worker never reclaimed, and it lands exactly at the `api-storage-policy` -> `api-storage` file boundary. That suite is the only one that spawns policy workers. `Worker.terminate()` returns void and does not wait for the thread to go away. Under `bun test --isolate` the harness reclaims a file's realm at the boundary, so a worker still exiting at that instant trips a Bun-internal assertion and takes the whole run down. macOS and Linux tolerate the same race, which is why it reads as a Windows-only flake. `worker-lifecycle.ts` tracks live workers and terminates them through the `close` event, so callers can await the thread actually being gone rather than a timer they hope is long enough; a 5s cap keeps a wedged worker from hanging teardown. Both storage jobs settle their run promise after that await, and the policy suite's `afterEach` now drains. devlog `_plan/260730_remote_issue_merge_round/150` reproduced this panic twice against an unchanged tree, confirmed the same tree passed in PR context, and left this defence as the follow-up if it recurred. It recurred. Evidence: 6190 pass / 0 fail across 445 files, typecheck clean, privacy scan green. The regression test asserts a worker was really spawned before checking it is gone, so it cannot pass vacuously.
1 parent c777c8e commit 1a46299

5 files changed

Lines changed: 238 additions & 12 deletions

File tree

src/storage/policy-job.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import {
1818
type PolicyRunResult,
1919
type PolicySkipReason,
2020
} from "./policy";
21+
import {
22+
drainStorageWorkers,
23+
registerStorageWorker,
24+
terminateStorageWorker,
25+
} from "./worker-lifecycle";
2126

2227
export type PolicyJobStatus = "idle" | "running";
2328

@@ -127,7 +132,7 @@ function disownActiveRun(): void {
127132
export function resetStorageCleanupPolicyJobForTests(): void {
128133
disownActiveRun();
129134
if (activeWorker) {
130-
try { activeWorker.terminate(); } catch { /* */ }
135+
void terminateStorageWorker(activeWorker);
131136
activeWorker = null;
132137
}
133138
inflight = null;
@@ -136,11 +141,24 @@ export function resetStorageCleanupPolicyJobForTests(): void {
136141
state = { status: "idle" };
137142
}
138143

144+
/**
145+
* Await-able sibling of the reset above, for test teardown.
146+
*
147+
* `bun test --isolate` reclaims a file's realm at the file boundary. A storage
148+
* worker still exiting at that moment trips a Bun-internal assertion on Windows
149+
* and takes the whole run down, so a suite that spawns workers must be able to
150+
* wait for them rather than fire-and-forget.
151+
*/
152+
export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void> {
153+
resetStorageCleanupPolicyJobForTests();
154+
await drainStorageWorkers();
155+
}
156+
139157
/** Terminate an in-flight worker during process shutdown. */
140158
export function abortStorageCleanupPolicyJob(): void {
141159
disownActiveRun();
142160
if (activeWorker) {
143-
try { activeWorker.terminate(); } catch { /* */ }
161+
void terminateStorageWorker(activeWorker);
144162
activeWorker = null;
145163
}
146164
releaseHeldMutationSlot();
@@ -218,13 +236,14 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
218236
const requestId = crypto.randomUUID();
219237
let settled = false;
220238
const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
239+
registerStorageWorker(worker);
221240
activeWorker = worker;
222241

223242
const timer = setTimeout(() => {
224243
if (settled) return;
225244
settled = true;
226245
cancelActiveRun = null;
227-
try { worker.terminate(); } catch { /* */ }
246+
void terminateStorageWorker(worker);
228247
if (activeWorker === worker) activeWorker = null;
229248
reject(new Error("storage_cleanup_worker_timeout"));
230249
}, WORKER_TIMEOUT_MS);
@@ -235,8 +254,10 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
235254
cancelActiveRun = null;
236255
clearTimeout(timer);
237256
if (activeWorker === worker) activeWorker = null;
238-
try { worker.terminate(); } catch { /* */ }
239-
fn();
257+
// Settle the caller only after the thread is actually gone, so a suite
258+
// that awaits its request cannot reach the next test file with a worker
259+
// still exiting behind it.
260+
void terminateStorageWorker(worker).then(fn, fn);
240261
};
241262

242263
cancelActiveRun = () => {

src/storage/restore-job.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
withStorageMutationSlot,
1717
type StorageMutationCoordinatorTestHooks,
1818
} from "./storage-mutation-coordinator";
19+
import {
20+
drainStorageWorkers,
21+
registerStorageWorker,
22+
terminateStorageWorker,
23+
} from "./worker-lifecycle";
1924

2025
export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
2126
/**
@@ -69,7 +74,7 @@ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null):
6974

7075
export function resetRestoreTrashJobForTests(): void {
7176
if (activeWorker) {
72-
try { activeWorker.terminate(); } catch { /* */ }
77+
void terminateStorageWorker(activeWorker);
7378
activeWorker = null;
7479
}
7580
cancelActiveRun?.();
@@ -78,10 +83,16 @@ export function resetRestoreTrashJobForTests(): void {
7883
resetStorageMutationCoordinatorForTests();
7984
}
8085

86+
/** Await-able reset for test teardown; see policy-job's equivalent for why. */
87+
export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
88+
resetRestoreTrashJobForTests();
89+
await drainStorageWorkers();
90+
}
91+
8192
/** Terminate an in-flight worker during process shutdown. */
8293
export function abortRestoreTrashJob(): void {
8394
if (activeWorker) {
84-
try { activeWorker.terminate(); } catch { /* */ }
95+
void terminateStorageWorker(activeWorker);
8596
activeWorker = null;
8697
}
8798
cancelActiveRun?.();
@@ -117,13 +128,14 @@ function runInWorker(opts: {
117128
const requestId = crypto.randomUUID();
118129
let settled = false;
119130
const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
131+
registerStorageWorker(worker);
120132
activeWorker = worker;
121133

122134
const timer = setTimeout(() => {
123135
if (settled) return;
124136
settled = true;
125137
cancelActiveRun = null;
126-
try { worker.terminate(); } catch { /* */ }
138+
void terminateStorageWorker(worker);
127139
if (activeWorker === worker) activeWorker = null;
128140
reject(new Error("restore_worker_timeout"));
129141
}, WORKER_TIMEOUT_MS);
@@ -134,8 +146,7 @@ function runInWorker(opts: {
134146
cancelActiveRun = null;
135147
clearTimeout(timer);
136148
if (activeWorker === worker) activeWorker = null;
137-
try { worker.terminate(); } catch { /* */ }
138-
fn();
149+
void terminateStorageWorker(worker).then(fn, fn);
139150
};
140151

141152
cancelActiveRun = () => {

src/storage/worker-lifecycle.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Deterministic teardown for the storage Bun Workers.
3+
*
4+
* `Worker.terminate()` returns void and does NOT wait for the thread to be
5+
* reclaimed. Every caller here used to fire it and move on, which is fine for
6+
* the proxy but not for `bun test --isolate`: the harness tears a test file's
7+
* realm down at the file boundary, and on Windows a worker that has not
8+
* finished exiting by then trips a Bun-internal assertion and kills the whole
9+
* run.
10+
*
11+
* The crash header names the shape exactly — `workers_spawned(9)
12+
* workers_terminated(8)`, one worker still alive — and it lands right at the
13+
* `api-storage-policy` → `api-storage` file boundary, the only suite that
14+
* spawns policy workers. See `devlog/_plan/260730_remote_issue_merge_round/150`,
15+
* which reproduced the panic twice against an unchanged tree and left this
16+
* defence as the follow-up if it ever recurred. It recurred.
17+
*
18+
* So: keep a registry of live workers, and give shutdown/reset paths something
19+
* they can actually await. `close` is Bun's post-exit event for a worker
20+
* thread, so awaiting it is the real "the thread is gone" signal rather than a
21+
* timer we hope is long enough. The timeout only exists so a wedged worker
22+
* cannot hang a test teardown forever.
23+
*/
24+
25+
const liveWorkers = new Set<Worker>();
26+
27+
/** Track a freshly spawned worker so teardown can wait for it later. */
28+
export function registerStorageWorker(worker: Worker): void {
29+
liveWorkers.add(worker);
30+
}
31+
32+
/**
33+
* Terminate a worker and resolve once its thread has actually exited.
34+
*
35+
* Safe to call twice: the second call finds the worker already deregistered and
36+
* resolves immediately.
37+
*/
38+
export function terminateStorageWorker(worker: Worker, timeoutMs = 5_000): Promise<void> {
39+
if (!liveWorkers.has(worker)) {
40+
try { worker.terminate(); } catch { /* already gone */ }
41+
return Promise.resolve();
42+
}
43+
liveWorkers.delete(worker);
44+
45+
return new Promise<void>(resolve => {
46+
let done = false;
47+
const settle = (): void => {
48+
if (done) return;
49+
done = true;
50+
clearTimeout(timer);
51+
resolve();
52+
};
53+
// A worker that refuses to exit must not wedge teardown; the proxy path
54+
// never awaits this, and a test teardown would rather continue than hang.
55+
const timer = setTimeout(settle, timeoutMs);
56+
try {
57+
worker.addEventListener("close", settle, { once: true });
58+
} catch {
59+
// No close event available — fall back to the timeout above.
60+
}
61+
try {
62+
worker.terminate();
63+
} catch {
64+
settle();
65+
}
66+
});
67+
}
68+
69+
/**
70+
* Await every worker this module still tracks. Used by test resets so no
71+
* storage worker outlives the file that spawned it.
72+
*/
73+
export async function drainStorageWorkers(timeoutMs = 5_000): Promise<void> {
74+
const pending = [...liveWorkers];
75+
await Promise.all(pending.map(worker => terminateStorageWorker(worker, timeoutMs)));
76+
}
77+
78+
/** Live worker count — exported so a regression test can assert the invariant. */
79+
export function liveStorageWorkerCount(): number {
80+
return liveWorkers.size;
81+
}

tests/api-storage-policy.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../src/storage/cleanup-job";
1515
import {
1616
resetStorageCleanupPolicyJobForTests,
17+
resetStorageCleanupPolicyJobForTestsAsync,
1718
setStorageCleanupPolicyJobTestHooks,
1819
} from "../src/storage/policy-job";
1920
import { stopStorageCleanupScheduler } from "../src/storage/policy-scheduler";
@@ -114,9 +115,12 @@ beforeEach(() => {
114115
resetArchivedCleanupJobForTests();
115116
});
116117

117-
afterEach(() => {
118+
afterEach(async () => {
118119
stopStorageCleanupScheduler();
119-
resetStorageCleanupPolicyJobForTests();
120+
// Await the worker teardown: under `bun test --isolate` an exiting Bun Worker
121+
// that outlives its test file crashes the Windows runner at the file
122+
// boundary (workers_spawned > workers_terminated in the panic header).
123+
await resetStorageCleanupPolicyJobForTestsAsync();
120124
setStorageCleanupPolicyJobTestHooks(null);
121125
resetArchivedCleanupJobForTests();
122126
setArchivedCleanupJobTestHooks(null);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* The storage workers must be GONE, not merely asked to stop, by the time a
3+
* test file's realm is torn down.
4+
*
5+
* `bun test --isolate` reclaims the realm at the file boundary. A Bun Worker
6+
* still exiting at that instant trips an internal assertion on Windows and
7+
* kills the entire run — no `(fail)` line, just
8+
* `panic(...): Internal assertion failure` with `workers_spawned(N)
9+
* workers_terminated(N-1)` in the header. That crashed `dev` and three PRs at
10+
* the `api-storage-policy` → `api-storage` boundary, the only place policy
11+
* workers are spawned.
12+
*
13+
* These assertions are the invariant the fix rests on: after a run settles, and
14+
* after a reset, nothing is left tracked.
15+
*/
16+
import { afterEach, beforeEach, expect, test } from "bun:test";
17+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
18+
import { tmpdir } from "node:os";
19+
import { join } from "node:path";
20+
import { Database } from "bun:sqlite";
21+
import {
22+
requestStorageCleanupPolicyRun,
23+
getStorageCleanupPolicyJobState,
24+
resetStorageCleanupPolicyJobForTestsAsync,
25+
setStorageCleanupPolicyJobTestHooks,
26+
} from "../src/storage/policy-job";
27+
import { liveStorageWorkerCount } from "../src/storage/worker-lifecycle";
28+
import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home";
29+
30+
let isolatedCodexHome: IsolatedCodexHome | null = null;
31+
let testDir = "";
32+
let previousHome: string | undefined;
33+
34+
function seedArchived(codexHome: string): void {
35+
mkdirSync(join(codexHome, "archived_sessions"), { recursive: true });
36+
writeFileSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), "o".repeat(100));
37+
const db = new Database(join(codexHome, "state_5.sqlite"));
38+
db.exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER)`);
39+
db.exec(`INSERT INTO threads VALUES ('told','archived_sessions/rollout-old.jsonl',1)`);
40+
db.close();
41+
}
42+
43+
beforeEach(() => {
44+
previousHome = process.env.OPENCODEX_HOME;
45+
isolatedCodexHome = installIsolatedCodexHome("ocx-worker-lifecycle-codex-");
46+
testDir = mkdtempSync(join(tmpdir(), "ocx-worker-lifecycle-"));
47+
process.env.OPENCODEX_HOME = testDir;
48+
});
49+
50+
afterEach(async () => {
51+
await resetStorageCleanupPolicyJobForTestsAsync();
52+
setStorageCleanupPolicyJobTestHooks(null);
53+
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
54+
else process.env.OPENCODEX_HOME = previousHome;
55+
isolatedCodexHome?.restore();
56+
isolatedCodexHome = null;
57+
if (testDir) rmSync(testDir, { recursive: true, force: true });
58+
testDir = "";
59+
});
60+
61+
async function waitForIdle(timeoutMs = 20_000): Promise<void> {
62+
const deadline = Date.now() + timeoutMs;
63+
while (Date.now() < deadline) {
64+
if (getStorageCleanupPolicyJobState().status === "idle") return;
65+
await Bun.sleep(25);
66+
}
67+
throw new Error("policy job did not settle");
68+
}
69+
70+
/** Guards against a vacuous pass: assert we really did spawn a worker. */
71+
async function waitForLiveWorker(timeoutMs = 10_000): Promise<void> {
72+
const deadline = Date.now() + timeoutMs;
73+
while (Date.now() < deadline) {
74+
if (liveStorageWorkerCount() > 0) return;
75+
await Bun.sleep(5);
76+
}
77+
throw new Error("no storage worker was ever spawned; this test would prove nothing");
78+
}
79+
80+
test("a settled policy worker leaves nothing alive behind it", async () => {
81+
seedArchived(isolatedCodexHome!.path);
82+
const started = requestStorageCleanupPolicyRun({
83+
reason: "manual",
84+
codexHome: isolatedCodexHome!.path,
85+
});
86+
expect(started.accepted).toBe(true);
87+
88+
await waitForLiveWorker();
89+
await waitForIdle();
90+
// The run reached a terminal state, so its worker thread must already be
91+
// reclaimed — not merely sent a terminate() that has yet to land.
92+
expect(liveStorageWorkerCount()).toBe(0);
93+
}, { timeout: 30_000 });
94+
95+
test("reset drains a worker that is still blocked mid-run", async () => {
96+
// A worker held inside its run is exactly the state that outlived the file
97+
// boundary in CI, so tear it down while it is still busy.
98+
setStorageCleanupPolicyJobTestHooks({ blockMs: 1_500 });
99+
seedArchived(isolatedCodexHome!.path);
100+
const started = requestStorageCleanupPolicyRun({
101+
reason: "manual",
102+
codexHome: isolatedCodexHome!.path,
103+
});
104+
expect(started.accepted).toBe(true);
105+
106+
await waitForLiveWorker();
107+
await resetStorageCleanupPolicyJobForTestsAsync();
108+
expect(liveStorageWorkerCount()).toBe(0);
109+
}, { timeout: 30_000 });

0 commit comments

Comments
 (0)