Skip to content

Commit a1ed691

Browse files
authored
Merge pull request #827 from Wibias/fix/storage-worker-isolate-beforeeach
fix(storage): await Worker join in test beforeEach under isolate
2 parents 63735af + 9931337 commit a1ed691

20 files changed

Lines changed: 242 additions & 108 deletions

.github/workflows/ci.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,13 @@ jobs:
120120
# the result rather than the code under review — #711's rerun finished at
121121
# 11.8min and passed while #653's was killed at 12.0min (issue #717).
122122
# A cancelled job renders as `fail` in `gh pr checks`, so that flakiness
123-
# reads as a broken PR. 20 minutes keeps a green Windows run green with
124-
# real margin; it is not a licence for the suite to grow into it. If
125-
# Windows approaches this ceiling too, fix the 2.5x platform gap instead
126-
# of raising the number again.
127-
timeout-minutes: 20
123+
# reads as a broken PR. After the 2026-08-01 state-store admission merge,
124+
# Windows under `bun test --isolate` on #827 hit the 20-minute kill while
125+
# still green mid-suite (~19m of tests). 30 minutes is the margin for that
126+
# tip — not a licence to absorb hung tests (see oauth mutation waitMs
127+
# unref fix). Shrink the suite / close the platform gap rather than raising
128+
# this again for ordinary variance.
129+
timeout-minutes: 30
128130
strategy:
129131
fail-fast: false
130132
matrix:

gui/tests/client-resource-poll.test.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, expect, test } from "bun:test";
1+
import { afterEach, beforeEach, expect, test as bunTest } from "bun:test";
22
import { Window } from "happy-dom";
33
import { act, useEffect, useState } from "react";
44
import type { Root } from "react-dom/client";
@@ -9,6 +9,13 @@ import {
99
useKeyedClientResource,
1010
} from "../src/client-resource";
1111

12+
// Bun's default per-test budget is 5s. waitFor below is 15s (busy CI runners),
13+
// so every case in this file needs a higher ceiling or the suite fails as
14+
// "waitFor timed out" while the predicate never got its full window.
15+
function test(name: string, fn: () => void | Promise<void>): void {
16+
bunTest(name, fn, { timeout: 30_000 });
17+
}
18+
1219
const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const;
1320
let previousGlobals: Record<(typeof globals)[number], unknown>;
1421
let testWindow: Window;
@@ -37,11 +44,13 @@ afterEach(() => {
3744
* 1s was enough on an idle machine and not enough on a loaded CI runner: the
3845
* visibility test waits for a poll that only fires after a real 20ms interval
3946
* plus a React commit, and macOS CI blew through the budget mid-suite while the
40-
* same file passed in isolation. The assertions below are about *whether* the
41-
* fetch happens, never about how fast, so a longer ceiling costs nothing on a
42-
* healthy run — it only stops a busy runner from reading as a product bug.
47+
* same file passed in isolation. Windows GHA later timed out the same waits at
48+
* 5s under suite load (`waitFor timed out` on #827). The assertions below are
49+
* about *whether* the fetch happens, never about how fast, so a longer ceiling
50+
* costs nothing on a healthy run — it only stops a busy runner from reading as
51+
* a product bug.
4352
*/
44-
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
53+
async function waitFor(predicate: () => boolean, timeoutMs = 15_000): Promise<void> {
4554
const start = Date.now();
4655
while (!predicate()) {
4756
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");

src/oauth/store.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,11 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
416416
release();
417417
rejectResult(new OAuthMutationBusyError("OAuth mutation queue wait timed out"));
418418
}, waitMs);
419-
entry.timeout.unref?.();
419+
// Only unref the long default wait. Short waitMs (tests) must stay ref'd:
420+
// on Windows Bun under `bun test --isolate`, an unref'd timer can fail to
421+
// fire while the head mutation holds an unresolved Promise, hanging the
422+
// waiter forever (#827).
423+
if (waitMs >= OAUTH_MUTATION_WAIT_MS) entry.timeout.unref?.();
420424
mutationWaiters.push(entry);
421425
drainOAuthMutations();
422426
return result;

src/server/lifecycle.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,9 @@ export async function drainAndShutdown(
217217
setStorageCleanupPolicyJobLiveApply(null);
218218
} finally {
219219
try {
220-
s?.stop(true);
220+
// Bun's Server.stop returns Promise<void>; fire-and-forget races the next
221+
// isolate reclaim / follow-on listen the same way unterminated Workers did.
222+
if (s) await s.stop(true);
221223
} finally {
222224
draining = false;
223225
}

src/storage/policy-job.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,16 @@ function disownActiveRun(): void {
131131
cancel?.();
132132
}
133133

134+
/**
135+
* Fire-and-forget reset. Prefer {@link resetStorageCleanupPolicyJobForTestsAsync}
136+
* from test beforeEach/afterEach — sync terminate races Windows
137+
* `bun test --isolate` reclaim when the next case spawns immediately.
138+
*/
134139
export function resetStorageCleanupPolicyJobForTests(): void {
135140
disownActiveRun();
141+
// Invalidate spawnGate callbacks that have not created a Worker yet — otherwise
142+
// they can still spawn after this sync path releases the mutation slot.
143+
cancelQueuedStorageWorkerSpawns();
136144
if (activeWorker) {
137145
void terminateStorageWorker(activeWorker);
138146
activeWorker = null;
@@ -176,6 +184,7 @@ export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void>
176184
/** Terminate an in-flight worker during process shutdown. */
177185
export function abortStorageCleanupPolicyJob(): void {
178186
disownActiveRun();
187+
cancelQueuedStorageWorkerSpawns();
179188
if (activeWorker) {
180189
void terminateStorageWorker(activeWorker);
181190
activeWorker = null;

src/storage/restore-job.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,12 @@ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null):
7575
setStorageMutationCoordinatorTestHooks(hooks);
7676
}
7777

78+
/**
79+
* Fire-and-forget reset. Prefer {@link resetRestoreTrashJobForTestsAsync} from
80+
* test beforeEach/afterEach under `bun test --isolate` on Windows.
81+
*/
7882
export function resetRestoreTrashJobForTests(): void {
83+
cancelQueuedStorageWorkerSpawns();
7984
if (activeWorker) {
8085
void terminateStorageWorker(activeWorker);
8186
activeWorker = null;
@@ -98,13 +103,19 @@ export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
98103
cancelActiveRun?.();
99104
cancelActiveRun = null;
100105
testHooks = null;
101-
resetStorageMutationCoordinatorForTests();
102-
if (worker) await terminateStorageWorker(worker);
103-
await drainStorageWorkers();
106+
// Join the worker before clearing the mutation coordinator so a concurrent
107+
// run cannot acquire CODEX_HOME while the aborted thread is still mutating.
108+
try {
109+
if (worker) await terminateStorageWorker(worker);
110+
await drainStorageWorkers();
111+
} finally {
112+
resetStorageMutationCoordinatorForTests();
113+
}
104114
}
105115

106116
/** Terminate an in-flight worker during process shutdown. */
107117
export function abortRestoreTrashJob(): void {
118+
cancelQueuedStorageWorkerSpawns();
108119
if (activeWorker) {
109120
void terminateStorageWorker(activeWorker);
110121
activeWorker = null;

src/storage/worker-lifecycle.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ let spawnCancelEpoch = 0;
5252
/**
5353
* OS-join gap after the `close` event on platforms where Bun's Worker reclaim
5454
* races the isolate/file boundary (not a CI job-timeout bump).
55+
* Windows GHA at 250ms still left `workers_spawned(N) workers_terminated(N-1)`
56+
* panics under isolate; 750ms covers deferred reclaim. Darwin uses 250ms.
5557
*/
56-
const WORKER_OS_JOIN_MS = 250;
58+
const WORKER_OS_JOIN_MS = process.platform === "win32" ? 750 : 250;
5759

5860
function needsWorkerOsJoinSettle(): boolean {
5961
return process.platform === "win32" || process.platform === "darwin";

tests/api-storage-policy-already-running.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
seedArchived,
1010
setStorageCleanupPolicyJobTestHooks,
1111
startServer,
12-
stopStorageCleanupScheduler,
12+
stopPolicyServer,
1313
uninstallPolicyApiHarness,
1414
waitForJobIdle,
1515
type PolicyApiHarness,
@@ -18,8 +18,8 @@ import {
1818

1919
let harness: PolicyApiHarness;
2020

21-
beforeEach(() => {
22-
harness = installPolicyApiHarness("ocx-api-storage-policy-busy");
21+
beforeEach(async () => {
22+
harness = await installPolicyApiHarness("ocx-api-storage-policy-busy");
2323
});
2424

2525
afterEach(async () => {
@@ -60,8 +60,7 @@ test("POST run rejects when a job is already running", async () => {
6060

6161
await waitForJobIdle(server.url, firstBody.job.startedAt);
6262
} finally {
63-
await server.stop(true);
64-
stopStorageCleanupScheduler();
63+
await stopPolicyServer(server);
6564
await resetStorageCleanupPolicyJobForTestsAsync();
6665
}
6766
}, { timeout: 30_000 });

tests/api-storage-policy-mutation-busy.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
seedArchived,
1010
setArchivedCleanupJobTestHooks,
1111
startServer,
12-
stopStorageCleanupScheduler,
12+
stopPolicyServer,
1313
uninstallPolicyApiHarness,
1414
waitForJobIdle,
1515
type PolicyApiHarness,
@@ -18,8 +18,8 @@ import {
1818

1919
let harness: PolicyApiHarness;
2020

21-
beforeEach(() => {
22-
harness = installPolicyApiHarness("ocx-api-storage-policy-mut-busy");
21+
beforeEach(async () => {
22+
harness = await installPolicyApiHarness("ocx-api-storage-policy-mut-busy");
2323
});
2424

2525
afterEach(async () => {
@@ -78,8 +78,7 @@ test("storage_mutation_busy clears inflight so a later policy run can start", as
7878
expect(retryDone.job.lastOutcome?.skipped).toBeUndefined();
7979
expect(retryDone.job.lastOutcome?.removed).toBe(1);
8080
} finally {
81-
await server.stop(true);
82-
stopStorageCleanupScheduler();
81+
await stopPolicyServer(server);
8382
await resetStorageCleanupPolicyJobForTestsAsync();
8483
}
8584
}, { timeout: 30_000 });

tests/api-storage-policy-put-race.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
seedArchived,
1010
setStorageCleanupPolicyJobTestHooks,
1111
startServer,
12-
stopStorageCleanupScheduler,
12+
stopPolicyServer,
1313
uninstallPolicyApiHarness,
1414
waitForJobIdle,
1515
type PolicyApiHarness,
@@ -18,8 +18,8 @@ import {
1818

1919
let harness: PolicyApiHarness;
2020

21-
beforeEach(() => {
22-
harness = installPolicyApiHarness("ocx-api-storage-policy-put-race");
21+
beforeEach(async () => {
22+
harness = await installPolicyApiHarness("ocx-api-storage-policy-put-race");
2323
});
2424

2525
afterEach(async () => {
@@ -109,8 +109,7 @@ test("blocked worker completion preserves concurrent policy PUT edits", async ()
109109
expect(typeof body.lastRun?.at).toBe("number");
110110
expect(typeof body.nextRun).toBe("number");
111111
} finally {
112-
await server.stop(true);
113-
stopStorageCleanupScheduler();
112+
await stopPolicyServer(server);
114113
await resetStorageCleanupPolicyJobForTestsAsync();
115114
}
116115
}, { timeout: 30_000 });

0 commit comments

Comments
 (0)