diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaec3fb06..8fa2248e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ on: permissions: contents: read +# Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46). concurrency: group: cross-platform-ci-${{ github.ref }} cancel-in-progress: true diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 2410fb8dd..a022f1833 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -336,9 +336,10 @@ function waitForBackgroundShellClose(entry: BackgroundShellEntry): Promise { let settled = false; // Deliberately REF'D: this is the bounded kill-grace wait that shutdown - // drain awaits. Bun on Windows can starve unref'd timers when a pending - // promise is the only other work, which would leave drainAndShutdown - // waiting on this resolution forever. The timer self-clears within the + // drain awaits. Bun on Windows / under `bun test --isolate` can starve + // unref'd timers when a pending promise is the only other work, which + // would leave drainAndShutdown waiting forever (same class as oauth + // serializeMutation wait timers). The timer self-clears within the // 2-second grace window (or earlier on close), so a ref cannot keep the // process alive beyond that bound. const timer = backgroundShellRuntime.setTimer(() => { diff --git a/src/config.ts b/src/config.ts index 88bf9564d..723aca09e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1530,7 +1530,10 @@ function configMutationDatabasePath(): string { } if (windowsSecretAclApplies()) { try { - hardenSecretDir(dir, { required: true }); + // Distinct timeout memo from management-token directory harden: a required + // management-dir timeout must not poison config mutation on the same home + // (windows-latest server-management-auth cases). + hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` }); } catch (error) { if (!warnedConfigMutationDirectoryAcl) { warnedConfigMutationDirectoryAcl = true; diff --git a/src/oauth/store.ts b/src/oauth/store.ts index c7f8ccf7c..084019c4a 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -419,7 +419,7 @@ function serializeMutation(work: () => Promise, retainedValues: readonly u // Only unref the long default wait. Short waitMs (tests) must stay ref'd: // on Windows Bun under `bun test --isolate`, an unref'd timer can fail to // fire while the head mutation holds an unresolved Promise, hanging the - // waiter forever (#827). + // waiter forever (#827 / full admission queue hang on windows-latest). if (waitMs >= OAUTH_MUTATION_WAIT_MS) entry.timeout.unref?.(); mutationWaiters.push(entry); drainOAuthMutations(); diff --git a/src/responses/spill-store.ts b/src/responses/spill-store.ts index c355444b6..12a727c78 100644 --- a/src/responses/spill-store.ts +++ b/src/responses/spill-store.ts @@ -59,6 +59,12 @@ export interface ResponseSpillCleanupResult { export interface ResponseSpillIoForTest { write?: (fd: number, bytes: Uint8Array) => void; fsync?: (fd: number) => void; + /** Directory-handle fsync seam for `fsyncDirectoryBestEffort` only. */ + fsyncDir?: (fd: number) => void; + /** Directory-handle open seam for `fsyncDirectoryBestEffort` only. */ + openDir?: (dir: string) => number; + /** Directory-handle close seam for `fsyncDirectoryBestEffort` only. */ + closeDir?: (fd: number) => void; link?: (tempPath: string, destinationPath: string) => void; copyFileExcl?: (tempPath: string, destinationPath: string) => void; unlink?: (path: string) => void; @@ -86,14 +92,22 @@ function fsyncDirectoryBestEffort(dir: string): void { // open/fsync directory handles this way, but callers still cross this seam. record("dir-fsync"); try { - fd = openSync(dir, "r"); - if (spillIoForTest?.fsync) spillIoForTest.fsync(fd); - else fsyncSync(fd); + fd = spillIoForTest?.openDir ? spillIoForTest.openDir(dir) : openSync(dir, "r"); + try { + if (spillIoForTest?.fsyncDir) spillIoForTest.fsyncDir(fd); + else if (spillIoForTest?.fsync) spillIoForTest.fsync(fd); + else fsyncSync(fd); + } catch { + // Windows and some filesystems do not support fsync on directory handles. + } } catch { - // Windows and some filesystems do not support fsync on directory handles. + // Directory missing or unreadable — nothing further to sync. } finally { if (fd !== null) { - try { closeSync(fd); } catch { /* best effort */ } + try { + if (spillIoForTest?.closeDir) spillIoForTest.closeDir(fd); + else closeSync(fd); + } catch { /* best effort */ } } } } diff --git a/src/storage/worker-lifecycle.ts b/src/storage/worker-lifecycle.ts index d7913b760..bed4946f6 100644 --- a/src/storage/worker-lifecycle.ts +++ b/src/storage/worker-lifecycle.ts @@ -52,10 +52,12 @@ let spawnCancelEpoch = 0; /** * OS-join gap after the `close` event on platforms where Bun's Worker reclaim * races the isolate/file boundary (not a CI job-timeout bump). - * Windows GHA at 250ms still left `workers_spawned(N) workers_terminated(N-1)` - * panics under isolate; 750ms covers deferred reclaim. Darwin uses 250ms. + * Windows GHA at 250ms and 750ms still left `workers_spawned(N) + * workers_terminated(N-1)` panics under isolate (seen mid + * `storage-mutation-race` with 11/10). 1500ms covers deferred reclaim under + * stacked policy/restore workers. Darwin uses 250ms. */ -const WORKER_OS_JOIN_MS = process.platform === "win32" ? 750 : 250; +const WORKER_OS_JOIN_MS = process.platform === "win32" ? 1_500 : 250; function needsWorkerOsJoinSettle(): boolean { return process.platform === "win32" || process.platform === "darwin"; diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index a33b41668..8fce54ef6 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -38,6 +38,8 @@ describe("Codex catalog restore", () => { if (existsSync(opencodexHome)) rmSync(opencodexHome, { recursive: true, force: true }); }); + // spawnSync(bun --eval) under `bun test --isolate` on Windows can exceed the + // default 5s case budget when the runner is under load (seen at ~5.4s on GHA). test("drops routed entries without overwriting user-added native entries", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); @@ -59,7 +61,7 @@ describe("Codex catalog restore", () => { expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 2 }); const slugs = JSON.parse(readFileSync(catalogPath, "utf8")).models.map((m: { slug: string }) => m.slug); expect(slugs).toEqual(["gpt-5.5", "user-native"]); - }); + }, { timeout: 15_000 }); test("uses pristine backup while preserving native entries added after sync", () => { const catalogPath = join(codexHome, "catalog.json"); @@ -94,7 +96,7 @@ describe("Codex catalog restore", () => { { slug: "codex-mini", priority: 60 }, { slug: "user-native", priority: 10 }, ]); - }); + }, { timeout: 15_000 }); test("does not apply generic legacy backup to a custom catalog path", () => { const catalogPath = join(codexHome, "custom-catalog.json"); @@ -120,7 +122,7 @@ describe("Codex catalog restore", () => { expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 2 }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(restored.map(m => m.slug)).toEqual(["gpt-5.5", "user-native"]); - }); + }, { timeout: 15_000 }); test("sync applies native-only subagent priority selections", () => { const catalogPath = join(codexHome, "catalog.json"); @@ -150,7 +152,7 @@ describe("Codex catalog restore", () => { const synced = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(synced.find(m => m.slug === "gpt-5.5")?.priority).toBe(0); expect(synced.find(m => m.slug === "gpt-5.4")?.priority).toBeGreaterThan(100); - }); + }, { timeout: 15_000 }); test("sync advertises documented Codex-native additions omitted by the bundled catalog", () => { const catalogPath = join(codexHome, "catalog.json"); @@ -196,5 +198,5 @@ describe("Codex catalog restore", () => { expect(synced.map(m => m.slug)).toContain("gpt-5.6-terra"); expect(synced.map(m => m.slug)).toContain("gpt-5.6-luna"); expect(synced.find(m => m.slug === "gpt-5.4")?.max_context_window).toBe(1_000_000); - }); + }, { timeout: 15_000 }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index d8e585fc0..e6ed95a1b 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1615,7 +1615,10 @@ describe("config.ts – Windows ACL hardening integration", () => { try { const spy = spyOn(windowsAcl, "hardenSecretDir").mockReturnValue({ ok: true }); saveConfig(getDefaultConfig()); - expect(spy).toHaveBeenCalledWith(testDir, { required: true }); + expect(spy).toHaveBeenCalledWith(testDir, { + required: true, + timeoutMemoKey: `${testDir}::config-mutation`, + }); expect(existsSync(getConfigPath())).toBe(true); spy.mockRestore(); } finally { diff --git a/tests/oauth-store-multi.test.ts b/tests/oauth-store-multi.test.ts index ff9996848..fcf09036c 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { INTERNAL_DEADLINE_MS, STORE_BUDGET_MS } from "./helpers/test-budget"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { @@ -263,6 +264,9 @@ describe("multi-account auth store", () => { expect(getAccountSet("xai")!.accounts[0]?.needsReauth).toBeUndefined(); }); + // Filling the 128-slot admission queue then draining it exceeds the default + // 5s case budget on a loaded Windows isolate runner; an early timeout also + // leaves the gate closed and hangs the file realm until the job ceiling. test("OAuth mutation 129 rejects before enqueue while every accepted mutation executes once", async () => { let releaseFirst!: () => void; let firstStarted!: () => void; @@ -274,18 +278,23 @@ describe("multi-account auth store", () => { firstStarted(); await firstGate; }, ["provider", "account"] )]; - await started; - for (let i = 1; i < 128; i++) { - accepted.push(mutateStore(() => { executions++; }, ["provider", `account-${i}`])); + try { + await started; + for (let i = 1; i < 128; i++) { + accepted.push(mutateStore(() => { executions++; }, ["provider", `account-${i}`])); + } + expect(oauthMutationTailSnapshot().active).toBe(128); + await expect(mutateStore(() => { executions++; }, ["rejected"])).rejects.toBeInstanceOf(OAuthMutationBusyError); + expect(oauthMutationTailSnapshot().active).toBe(128); + releaseFirst(); + await Promise.all(accepted); + expect(executions).toBe(128); + expect(oauthMutationTailSnapshot().active).toBe(0); + } finally { + releaseFirst(); + await Promise.allSettled(accepted); } - expect(oauthMutationTailSnapshot().active).toBe(128); - await expect(mutateStore(() => { executions++; }, ["rejected"])).rejects.toBeInstanceOf(OAuthMutationBusyError); - expect(oauthMutationTailSnapshot().active).toBe(128); - releaseFirst(); - await Promise.all(accepted); - expect(executions).toBe(128); - expect(oauthMutationTailSnapshot().active).toBe(0); - }); + }, { timeout: STORE_BUDGET_MS }); // 128 serialized load-modify-persist store mutations; windows-latest measured ~7.3s against Bun's 5s default. test("OAuth 30 second wait timeout releases an unstarted lease and never enters the chain", async () => { let releaseFirst!: () => void; @@ -296,24 +305,43 @@ describe("multi-account auth store", () => { firstStarted(); await firstGate; }, ["provider", "running-account"]); - await started; - let entered = false; - const timedOut = mutateStore(() => { entered = true; }, ["provider", "waiting-account"], { waitMs: 10 }); - // Bun 1.3.14 on Windows does not service the production-unref'ed queue timer - // when the test is otherwise waiting only on promises. Keep a test-owned - // ref'ed timer active, and release the blocker if the assertion itself fails. - const timerWakeup = setInterval(() => {}, 5); - const cleanupFallback = setTimeout(releaseFirst, 1_000); + let timedOut: Promise | undefined; try { - await expect(timedOut).rejects.toBeInstanceOf(OAuthMutationBusyError); + await started; + let entered = false; + timedOut = mutateStore(() => { entered = true; }, ["provider", "waiting-account"], { waitMs: 10 }); + // Belt-and-suspenders with the ref'd wait timer in store.ts: if reject still + // never fires under isolate load, fail the case instead of hanging the job. + // Use a clearable setTimeout (not Bun.sleep) so a settled race cannot keep + // the isolate alive for the remainder of INTERNAL_DEADLINE_MS. + let deadlineTimer: ReturnType | undefined; + try { + await Promise.race([ + timedOut.then( + () => { + throw new Error("expected OAuthMutationBusyError from waitMs timeout"); + }, + (error: unknown) => { + expect(error).toBeInstanceOf(OAuthMutationBusyError); + }, + ), + new Promise((_, reject) => { + deadlineTimer = setTimeout(() => { + reject(new Error(`OAuth mutation waitMs reject did not fire within ${INTERNAL_DEADLINE_MS}ms`)); + }, INTERNAL_DEADLINE_MS); + }), + ]); + } finally { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + } expect(entered).toBe(false); expect(oauthMutationTailSnapshot().active).toBe(1); - } finally { - clearInterval(timerWakeup); - clearTimeout(cleanupFallback); releaseFirst(); await blocker; + expect(oauthMutationTailSnapshot().active).toBe(0); + } finally { + releaseFirst(); + await Promise.allSettled([blocker, ...(timedOut ? [timedOut] : [])]); } - expect(oauthMutationTailSnapshot().active).toBe(0); - }, 5_000); // waitMs is 10; keep a hard ceiling so an unref regression cannot hang CI. + }, { timeout: STORE_BUDGET_MS }); }); diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 41887f292..337b22910 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { BULK_DURABLE_IO_BUDGET_MS } from "./helpers/test-budget"; import { + closeSync, existsSync, linkSync, mkdirSync, @@ -617,6 +618,39 @@ describe("Responses previous_response_id state", () => { expect(events).toEqual(["dir-fsync"]); }); + test("directory fsync still records and closes when fsync throws", () => { + const events: string[] = []; + const closed: number[] = []; + setSpillIoForTest({ + record: event => events.push(event), + fsyncDir: () => { + throw new Error("injected directory fsync failure"); + }, + closeDir: fd => { + closed.push(fd); + closeSync(fd); + }, + }); + writeResponseSpillDurably("resp_dir_fsync_throw", { createdAt: Date.now(), items: ["x"] }); + expect(events).toContain("dir-fsync"); + expect(closed).toHaveLength(1); + }); + + test("directory fsync records even when directory open fails", () => { + const events: string[] = []; + setSpillIoForTest({ + record: event => events.push(event), + openDir: () => { + throw Object.assign(new Error("injected directory open failure"), { code: "ENOENT" }); + }, + }); + writeResponseSpillDurably("resp_dir_open_fail", { createdAt: Date.now(), items: ["x"] }); + // Record happens before open so Windows (no directory-handle fsync) still + // observes the durability seam in ordering tests. + expect(events).toContain("dir-fsync"); + expect(events).toContain("publish"); + }); + test("spill temp cleanup forgets successful ACL memos and retains failed removals", () => { const previousUsername = process.env.USERNAME; process.env.USERNAME = "ocx-test-user"; diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index ead23cd89..615bb0b19 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -474,7 +474,7 @@ describe("management and data-plane credential separation", () => { } }); - test("directory ACL timeout keeps management unavailable and names OPENCODEX_ADMIN_AUTH_TOKEN", async () => { + test("directory ACL timeout keeps management unavailable and names OPENCODEX_ADMIN_AUTH_TOKEN", () => { delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; saveConfig(remoteConfig()); const adminToken = `ocx_admin_${"d".repeat(43)}`; @@ -492,25 +492,13 @@ describe("management and data-plane credential separation", () => { return { success: true, exitCode: 0, timedOut: false, stdout: "" }; }); resetHardenedStateForTests(); + // Probe only: startServer would re-harden the same home for config mutation + // and poison/conflict with this required directory timeout. HTTP 503 coverage + // for ACL timeouts lives in "an icacls timeout keeps the management plane closed". const state = initializeManagementAuthState(remoteConfig()); expect(state.available).toBe(false); if (state.available) return; expect(state.reason).toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); - - const server = startServer(0); - try { - const settings = await fetch(new URL("/api/settings", server.url), { - headers: { "x-opencodex-api-key": adminToken }, - }); - expect(settings.status).toBe(503); - const body = await settings.json() as { error?: string; hint?: string; reason?: string }; - expect(body.error).toBe("management API unavailable"); - expect(body.hint).toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); - expect(body.reason).toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); - expect((await fetch(new URL("/healthz", server.url))).status).toBe(200); - } finally { - await server.stop(true); - } }); test("required management harden retries after a soft loadConfig directory timeout", async () => { @@ -555,7 +543,8 @@ describe("management and data-plane credential separation", () => { setPlatformForTests("win32"); // Env-token init never needs file ACL. Time out management-token paths so a // broken file-backed ACL cannot be what made management available; allow - // other file hardens so startServer → saveConfig works on real win32. + // other file hardens so startServer → saveConfig works on real win32 + // (config-mutation directory harden soft-fails home timeouts). setIcaclsRunnerForTests(args => { const target = args[0] ?? ""; if (target === testHome || target.endsWith("admin-api-token")) { diff --git a/tests/shutdown-drain.test.ts b/tests/shutdown-drain.test.ts index e0005117e..3a10d643f 100644 --- a/tests/shutdown-drain.test.ts +++ b/tests/shutdown-drain.test.ts @@ -118,6 +118,8 @@ describe("background shell shutdown drain", () => { test("shell drain rejection or unresolved termination still calls server.stop", async () => { const unresolvedChild = installShutdownShell(); setBackgroundShellRuntimeForTests({ + // Collapse grace waits to next tick; keep the timer ref'd so isolate does + // not starve the waiter the way an unref'd setTimeout can. setTimer(callback) { // Keep this fixture timer REF'D: Bun on Windows can stop servicing // unref'd timers while the test's only pending work is a promise, diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index 4ca8eb3ea..43079802d 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -729,6 +729,8 @@ describe("executeArchivedCleanup", () => { { timeout: STORE_BUDGET_MS }, ); + // Same Windows satellite-rollback budget as the injected-mutation cases above: + // failBeforeStateCommit + failSatelliteRestore measured ~10s on windows-latest. test("satellite restore failure keeps recovery trashDir and manifest", () => { home = buildHome({ withSatelliteStores: true }); const result = runWithDigest(100, "permanent", home, { @@ -742,7 +744,7 @@ describe("executeArchivedCleanup", () => { expect(existsSync(join(home, ".trash", "94", "satellite-backup.json"))).toBe(true); // Files are still restored; trash is kept for DB recovery metadata. expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); - }); + }, { timeout: STORE_BUDGET_MS }); test("satellite-backup write failure leaves every database and rollout unchanged", () => { home = buildHome({ withSatelliteStores: true }); diff --git a/tests/storage-worker-lifecycle.test.ts b/tests/storage-worker-lifecycle.test.ts index 31dac86c8..10bb570c9 100644 --- a/tests/storage-worker-lifecycle.test.ts +++ b/tests/storage-worker-lifecycle.test.ts @@ -24,7 +24,14 @@ import { resetStorageCleanupPolicyJobForTestsAsync, setStorageCleanupPolicyJobTestHooks, } from "../src/storage/policy-job"; -import { liveStorageWorkerCount, tryReserveStorageWorker, withStorageWorkerSpawnGate } from "../src/storage/worker-lifecycle"; +import { + drainStorageWorkers, + liveStorageWorkerCount, + registerStorageWorker, + terminateStorageWorker, + tryReserveStorageWorker, + withStorageWorkerSpawnGate, +} from "../src/storage/worker-lifecycle"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; let isolatedCodexHome: IsolatedCodexHome | null = null; @@ -50,6 +57,7 @@ beforeEach(() => { afterEach(async () => { await resetStorageCleanupPolicyJobForTestsAsync(); setStorageCleanupPolicyJobTestHooks(null); + await drainStorageWorkers(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -82,7 +90,7 @@ test("a settled policy worker leaves nothing alive behind it", async () => { const started = requestStorageCleanupPolicyRun({ reason: "manual", codexHome: isolatedCodexHome!.path, -}); + }); expect(started.accepted).toBe(true); @@ -124,3 +132,22 @@ test("reset drains a worker that is still blocked mid-run", async () => { await resetStorageCleanupPolicyJobForTestsAsync(); expect(liveStorageWorkerCount()).toBe(0); }, { timeout: 30_000 }); + +test("close that wins before OS-join settle does not throw a late timeout", async () => { + // On win32/darwin the settle sleep (250ms) outlasts a short timeoutMs. If the + // timer stays armed across that gap, close can win and still throw. + const closeListeners: Array<() => void> = []; + const worker = { + terminate() {}, + addEventListener(type: string, fn: () => void) { + if (type === "close") closeListeners.push(fn); + }, + } as unknown as Worker; + registerStorageWorker(worker); + expect(liveStorageWorkerCount()).toBe(1); + expect(closeListeners.length).toBe(1); + const done = terminateStorageWorker(worker, 80); + for (const fn of closeListeners) fn(); + await expect(done).resolves.toBeUndefined(); + expect(liveStorageWorkerCount()).toBe(0); +}, { timeout: 10_000 }); diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 3e13782d0..127fae1c3 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,6 +22,11 @@ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../src/codex/ac import { clearAccountQuota, updateAccountQuota } from "../src/codex/quota"; import type { OcxConfig } from "../src/types"; +// beforeEach writes three Codex credentials (NTFS ACL harden on Windows). Under +// `bun test --isolate` on a loaded windows-latest runner that can exceed the +// default 5s hook budget (seen as beforeEach/afterEach timeout at ~7.6s). +setDefaultTimeout(30_000); + const savedCodexHome = process.env.CODEX_HOME; const savedOpencodexHome = process.env.OPENCODEX_HOME; let testDir: string; @@ -70,6 +75,8 @@ function codexHomeFixture(): string { return dir; } +// Credential writes can hit Windows ACL harden stalls under full-suite isolate +// load (GHA windows-latest: beforeEach/afterEach hook timed out ~7.6s). beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "ocx-subagent-fb-")); process.env.OPENCODEX_HOME = testDir; @@ -81,7 +88,7 @@ beforeEach(() => { clearAccountNeedsReauth("account-a"); clearAccountNeedsReauth("account-b"); clearAccountNeedsReauth("main"); -}); +}, { timeout: 30_000 }); afterEach(() => { if (savedCodexHome === undefined) delete process.env.CODEX_HOME; @@ -95,7 +102,7 @@ afterEach(() => { clearAccountNeedsReauth("account-b"); clearAccountNeedsReauth("main"); rmSync(testDir, { recursive: true, force: true }); -}); +}, { timeout: 30_000 }); describe("subagent model fallback chain", () => { test("buildSubagentModelChain dedupes and preserves order", () => {