|
| 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