Skip to content

Commit 7b6f4f4

Browse files
authored
Merge pull request #813 from Wibias/fix/ci-windows-no-isolate
fix(storage): await Worker exit before isolate reclaim on Windows
2 parents 86a8224 + 797dec1 commit 7b6f4f4

19 files changed

Lines changed: 919 additions & 455 deletions

src/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,10 @@ export function backupConfigBeforeOpenAiTierMigration(
295295
write: (target, bytes) => writeFileSync(target, bytes),
296296
harden: target => {
297297
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
298-
if (process.platform === "win32") hardenSecretPath(target, { required: true });
298+
// Soft-fail: a wedged/failed icacls on CI temp volumes must not abort
299+
// startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR).
300+
// chmod above still applies; live credential writes keep required:true.
301+
if (process.platform === "win32") hardenSecretPath(target, { required: false });
299302
},
300303
publishNoReplace: (temp, backup) => linkSync(temp, backup),
301304
truncate: target => truncateSync(target, 0),

src/server/lifecycle.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { flushResponseState } from "../responses/state";
22
import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
33
import {
4-
abortStorageCleanupPolicyJob,
4+
abortStorageCleanupPolicyJobAsync,
55
setStorageCleanupPolicyJobLiveApply,
66
} from "../storage/policy-job";
7+
import { abortRestoreTrashJobAsync } from "../storage/restore-job";
78
import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
9+
import {
10+
cancelQueuedStorageWorkerSpawns,
11+
drainStorageWorkers,
12+
} from "../storage/worker-lifecycle";
813

914
// ---------------------------------------------------------------------------
1015
// Active turn tracking + graceful shutdown drain
@@ -88,8 +93,32 @@ export async function drainAndShutdown(
8893
// previous_response_id chain survives the restart this shutdown is usually part of.
8994
await flushResponseState();
9095
// Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop.
96+
// Await worker thread exit: on Windows, a still-exiting Bun Worker under
97+
// `bun test --isolate` panics the whole process at the next realm reclaim.
98+
// Abort each job independently so one wedged join cannot skip the other,
99+
// then drain leftovers; failures must not prevent `server.stop`.
91100
stopStorageCleanupScheduler();
92-
abortStorageCleanupPolicyJob();
101+
cancelQueuedStorageWorkerSpawns();
102+
const shutdownJoins = await Promise.allSettled([
103+
abortStorageCleanupPolicyJobAsync(),
104+
abortRestoreTrashJobAsync(),
105+
]);
106+
for (const result of shutdownJoins) {
107+
if (result.status === "rejected") {
108+
console.warn(
109+
"[storage] worker abort during shutdown failed:",
110+
result.reason instanceof Error ? result.reason.message : result.reason,
111+
);
112+
}
113+
}
114+
try {
115+
await drainStorageWorkers();
116+
} catch (err) {
117+
console.warn(
118+
"[storage] worker drain during shutdown failed:",
119+
err instanceof Error ? err.message : err,
120+
);
121+
}
93122
setStorageCleanupPolicyLiveSink(null);
94123
setStorageCleanupPolicyJobLiveApply(null);
95124
s?.stop(true);

src/server/relay.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -854,8 +854,10 @@ function startBoundedInspectionPump(options: InspectionPumpOptions): void {
854854
stopDrain();
855855
return;
856856
}
857+
// Do not unref: on Bun/Windows a pending `reader.read()` can be the only
858+
// wake source; an unref'd timer may never run, so a silent post-cancel
859+
// drain (time bound, no bytes) hangs the suite until the job timeout.
857860
drainTimer = setTimeout(stopDrain, drainMs);
858-
(drainTimer as { unref?: () => void }).unref?.();
859861
};
860862
// Ends the bounded drain by cancelling the reader: the pending read settles
861863
// and the pump loop observes `drainStopped`. Deliberately NOT a shared

src/storage/policy-job.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import {
1919
type PolicySkipReason,
2020
} from "./policy";
2121
import {
22+
cancelQueuedStorageWorkerSpawns,
2223
drainStorageWorkers,
2324
registerStorageWorker,
2425
terminateStorageWorker,
26+
withStorageWorkerSpawnGate,
2527
} from "./worker-lifecycle";
2628

2729
export type PolicyJobStatus = "idle" | "running";
@@ -148,10 +150,27 @@ export function resetStorageCleanupPolicyJobForTests(): void {
148150
* worker still exiting at that moment trips a Bun-internal assertion on Windows
149151
* and takes the whole run down, so a suite that spawns workers must be able to
150152
* wait for them rather than fire-and-forget.
153+
*
154+
* Do not route through the sync reset's `void terminate(...)`: that used to
155+
* deregister the worker before the thread exited, so a follow-up drain saw an
156+
* empty registry and returned while Windows was still reclaiming the thread.
151157
*/
152158
export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void> {
153-
resetStorageCleanupPolicyJobForTests();
154-
await drainStorageWorkers();
159+
disownActiveRun();
160+
cancelQueuedStorageWorkerSpawns();
161+
const worker = activeWorker;
162+
activeWorker = null;
163+
inflight = null;
164+
testHooks = null;
165+
state = { status: "idle" };
166+
// Join the worker before releasing the mutation slot so a concurrent run
167+
// cannot acquire CODEX_HOME while the aborted thread is still mutating.
168+
try {
169+
if (worker) await terminateStorageWorker(worker);
170+
await drainStorageWorkers();
171+
} finally {
172+
releaseHeldMutationSlot();
173+
}
155174
}
156175

157176
/** Terminate an in-flight worker during process shutdown. */
@@ -161,6 +180,8 @@ export function abortStorageCleanupPolicyJob(): void {
161180
void terminateStorageWorker(activeWorker);
162181
activeWorker = null;
163182
}
183+
// Sync path cannot join; prefer abortStorageCleanupPolicyJobAsync when the
184+
// caller can await so the mutation slot stays held until the thread exits.
164185
releaseHeldMutationSlot();
165186
if (state.status === "running") {
166187
state = {
@@ -177,6 +198,37 @@ export function abortStorageCleanupPolicyJob(): void {
177198
inflight = null;
178199
}
179200

201+
/**
202+
* Shutdown path that joins worker threads. Prefer this over the sync abort when
203+
* the caller can await (server drain, test teardown) so Windows isolate reclaim
204+
* does not race a still-exiting storage worker.
205+
*/
206+
export async function abortStorageCleanupPolicyJobAsync(): Promise<void> {
207+
disownActiveRun();
208+
cancelQueuedStorageWorkerSpawns();
209+
const worker = activeWorker;
210+
activeWorker = null;
211+
if (state.status === "running") {
212+
state = {
213+
...state,
214+
status: "idle",
215+
finishedAt: Date.now(),
216+
lastError: "aborted",
217+
lastOutcome: {
218+
ok: false,
219+
error: "evaluation_failed",
220+
},
221+
};
222+
}
223+
inflight = null;
224+
try {
225+
if (worker) await terminateStorageWorker(worker);
226+
await drainStorageWorkers();
227+
} finally {
228+
releaseHeldMutationSlot();
229+
}
230+
}
231+
180232
function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
181233
return {
182234
ok: result.ok,
@@ -232,22 +284,13 @@ function applyMutationBusy(): void {
232284
}
233285

234286
function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Promise<PolicyRunResult> {
235-
return new Promise((resolve, reject) => {
287+
return withStorageWorkerSpawnGate(() => new Promise<PolicyRunResult>((resolve, reject) => {
236288
const requestId = crypto.randomUUID();
237289
let settled = false;
238290
const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
239291
registerStorageWorker(worker);
240292
activeWorker = worker;
241293

242-
const timer = setTimeout(() => {
243-
if (settled) return;
244-
settled = true;
245-
cancelActiveRun = null;
246-
void terminateStorageWorker(worker);
247-
if (activeWorker === worker) activeWorker = null;
248-
reject(new Error("storage_cleanup_worker_timeout"));
249-
}, WORKER_TIMEOUT_MS);
250-
251294
const finish = (fn: () => void) => {
252295
if (settled) return;
253296
settled = true;
@@ -256,10 +299,15 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
256299
if (activeWorker === worker) activeWorker = null;
257300
// Settle the caller only after the thread is actually gone, so a suite
258301
// that awaits its request cannot reach the next test file with a worker
259-
// still exiting behind it.
302+
// still exiting behind it. Also keeps executeJob's finally from releasing
303+
// the mutation slot while the thread may still mutate CODEX_HOME.
260304
void terminateStorageWorker(worker).then(fn, fn);
261305
};
262306

307+
const timer = setTimeout(() => {
308+
finish(() => reject(new Error("storage_cleanup_worker_timeout")));
309+
}, WORKER_TIMEOUT_MS);
310+
263311
cancelActiveRun = () => {
264312
finish(() => reject(new Error("aborted")));
265313
};
@@ -296,7 +344,7 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
296344
...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
297345
},
298346
});
299-
});
347+
}));
300348
}
301349

302350
async function executeJob(opts: RequestPolicyRunOptions): Promise<void> {

src/storage/policy-worker.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,11 @@ self.onmessage = (event: MessageEvent<unknown>) => {
4949
requestId,
5050
message: err instanceof Error ? err.message : "worker_failed",
5151
});
52+
} finally {
53+
// Close from inside the worker so the thread begins exiting before the
54+
// parent’s terminate() races isolate realm reclaim on Windows.
55+
try {
56+
(self as unknown as { close?: () => void }).close?.();
57+
} catch { /* already closing */ }
5258
}
5359
};

src/storage/restore-job.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ import {
1717
type StorageMutationCoordinatorTestHooks,
1818
} from "./storage-mutation-coordinator";
1919
import {
20+
cancelQueuedStorageWorkerSpawns,
2021
drainStorageWorkers,
2122
registerStorageWorker,
2223
terminateStorageWorker,
24+
withStorageWorkerSpawnGate,
2325
} from "./worker-lifecycle";
2426

2527
export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
@@ -83,9 +85,20 @@ export function resetRestoreTrashJobForTests(): void {
8385
resetStorageMutationCoordinatorForTests();
8486
}
8587

86-
/** Await-able reset for test teardown; see policy-job's equivalent for why. */
88+
/**
89+
* Await-able reset for test teardown; see policy-job's equivalent for why.
90+
* Awaits the active worker directly so drain cannot race a fire-and-forget
91+
* deregister from the sync reset.
92+
*/
8793
export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
88-
resetRestoreTrashJobForTests();
94+
cancelQueuedStorageWorkerSpawns();
95+
const worker = activeWorker;
96+
activeWorker = null;
97+
cancelActiveRun?.();
98+
cancelActiveRun = null;
99+
testHooks = null;
100+
resetStorageMutationCoordinatorForTests();
101+
if (worker) await terminateStorageWorker(worker);
89102
await drainStorageWorkers();
90103
}
91104

@@ -99,6 +112,17 @@ export function abortRestoreTrashJob(): void {
99112
cancelActiveRun = null;
100113
}
101114

115+
/** Await-able shutdown sibling; joins the restore worker thread before returning. */
116+
export async function abortRestoreTrashJobAsync(): Promise<void> {
117+
cancelQueuedStorageWorkerSpawns();
118+
const worker = activeWorker;
119+
activeWorker = null;
120+
cancelActiveRun?.();
121+
cancelActiveRun = null;
122+
if (worker) await terminateStorageWorker(worker);
123+
await drainStorageWorkers();
124+
}
125+
102126
/** Test-only SSE/text stream served from the proxy while a worker is blocked. */
103127
export function getRestoreTrashTestStreamResponse(): Response | null {
104128
if (!testHooks?.enableTestStream) return null;
@@ -124,31 +148,28 @@ function runInWorker(opts: {
124148
blockMs?: number;
125149
restoreTest?: RestoreTestHooks;
126150
}): Promise<RestoreResult> {
127-
return new Promise((resolve, reject) => {
151+
return withStorageWorkerSpawnGate(() => new Promise<RestoreResult>((resolve, reject) => {
128152
const requestId = crypto.randomUUID();
129153
let settled = false;
130154
const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
131155
registerStorageWorker(worker);
132156
activeWorker = worker;
133157

134-
const timer = setTimeout(() => {
135-
if (settled) return;
136-
settled = true;
137-
cancelActiveRun = null;
138-
void terminateStorageWorker(worker);
139-
if (activeWorker === worker) activeWorker = null;
140-
reject(new Error("restore_worker_timeout"));
141-
}, WORKER_TIMEOUT_MS);
142-
143158
const finish = (fn: () => void) => {
144159
if (settled) return;
145160
settled = true;
146161
cancelActiveRun = null;
147162
clearTimeout(timer);
148163
if (activeWorker === worker) activeWorker = null;
164+
// Join before settle so mutation coordination inside the restore worker
165+
// cannot overlap a follow-on run after a watchdog timeout.
149166
void terminateStorageWorker(worker).then(fn, fn);
150167
};
151168

169+
const timer = setTimeout(() => {
170+
finish(() => reject(new Error("restore_worker_timeout")));
171+
}, WORKER_TIMEOUT_MS);
172+
152173
cancelActiveRun = () => {
153174
finish(() => reject(new Error("aborted")));
154175
};
@@ -185,7 +206,7 @@ function runInWorker(opts: {
185206
...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
186207
},
187208
});
188-
});
209+
}));
189210
}
190211

191212
async function executeRestore(opts: {

src/storage/restore-worker.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ self.onmessage = async (event: MessageEvent<unknown>) => {
4646
requestId,
4747
message: err instanceof Error ? err.message : "worker_failed",
4848
});
49+
} finally {
50+
// Close from inside the worker so the thread begins exiting before the
51+
// parent’s terminate() races isolate realm reclaim on Windows.
52+
try {
53+
(self as unknown as { close?: () => void }).close?.();
54+
} catch { /* already closing */ }
4955
}
5056
};
5157

0 commit comments

Comments
 (0)