Skip to content

Commit 6294fe2

Browse files
Merge issue 776 follow-up: shared worker coordination on Bun 1.3, per-kind pool keys, startup watchdog at dispatch
Co-authored-by: Alfonso <alfonso-magic-context@users.noreply.github.com>
2 parents 261eb58 + 72be0a6 commit 6294fe2

5 files changed

Lines changed: 272 additions & 24 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect, test } from "bun:test";
2+
import { join } from "node:path";
3+
import { initializeDatabase } from "../../features/magic-context/storage-db";
4+
import { resetReusableWorkersForTests } from "../../shared/reusable-worker";
5+
import { Database } from "../../shared/sqlite";
6+
import { createTestTempDir } from "../../shared/test-temp-dir";
7+
import { resetRawSessionOrdinalWarmupsForTest, runSessionWarmupWorker } from "./raw-ordinal-warmup";
8+
9+
test("queued indexing does not spend its startup deadline before dispatch", async () => {
10+
const { dir, cleanup } = createTestTempDir("raw-index-pool-");
11+
const path = join(dir, "fixture.db");
12+
const db = new Database(path);
13+
const coordination = new Int32Array(new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT));
14+
Atomics.store(coordination, 0, 1);
15+
let release: ReturnType<typeof setTimeout> | undefined;
16+
try {
17+
initializeDatabase(db);
18+
const first = runSessionWarmupWorker(
19+
{
20+
path,
21+
sessionId: "index-pool",
22+
harness: "opencode",
23+
indexMessage: {
24+
id: "first",
25+
ordinal: 1,
26+
role: "user",
27+
parts: [{ type: "text", text: "first source" }],
28+
},
29+
indexCoordination: coordination.buffer,
30+
indexOwner: 1,
31+
},
32+
{ acceptMs: 5_000, resultMs: 5_000 },
33+
);
34+
const queued = runSessionWarmupWorker(
35+
{
36+
path,
37+
sessionId: "index-pool",
38+
harness: "opencode",
39+
indexMessage: {
40+
id: "queued",
41+
ordinal: 2,
42+
role: "user",
43+
parts: [{ type: "text", text: "queued source" }],
44+
},
45+
indexCoordination: coordination.buffer,
46+
indexOwner: 2,
47+
},
48+
{ acceptMs: 200, resultMs: 5_000 },
49+
);
50+
release = setTimeout(() => {
51+
Atomics.store(coordination, 0, 0);
52+
Atomics.notify(coordination, 0);
53+
}, 500);
54+
const [firstResult, queuedResult] = await Promise.all([first, queued]);
55+
expect(firstResult).toMatchObject({ inserted: true });
56+
expect(queuedResult).toMatchObject({ inserted: true });
57+
expect(
58+
db.prepare("SELECT message_id FROM message_history_fts ORDER BY message_id").all(),
59+
).toEqual([{ message_id: "first" }, { message_id: "queued" }]);
60+
} finally {
61+
clearTimeout(release);
62+
Atomics.store(coordination, 0, 0);
63+
resetRawSessionOrdinalWarmupsForTest();
64+
await resetReusableWorkersForTests();
65+
db.close();
66+
cleanup();
67+
}
68+
});
69+
70+
test("different raw job kinds cannot queue behind an index writer", async () => {
71+
const { dir, cleanup } = createTestTempDir("raw-index-kinds-");
72+
const path = join(dir, "fixture.db");
73+
const db = new Database(path);
74+
const coordination = new Int32Array(new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT));
75+
Atomics.store(coordination, 0, 1);
76+
try {
77+
initializeDatabase(db);
78+
let writerSettled = false;
79+
const writer = runSessionWarmupWorker(
80+
{
81+
path,
82+
sessionId: "index-kinds",
83+
harness: "opencode",
84+
indexMessage: {
85+
id: "writer",
86+
ordinal: 1,
87+
role: "user",
88+
parts: [{ type: "text", text: "writer source" }],
89+
},
90+
indexCoordination: coordination.buffer,
91+
indexOwner: 1,
92+
},
93+
{ acceptMs: 5_000, resultMs: 1_000 },
94+
).then((result) => {
95+
writerSettled = true;
96+
return result;
97+
});
98+
const backfill = await runSessionWarmupWorker(
99+
{ path, sessionId: "", harness: "opencode", ftsRowidBackfill: true },
100+
{ acceptMs: 5_000, resultMs: 5_000 },
101+
);
102+
expect(backfill).toMatchObject({ ftsRowidBackfill: true });
103+
expect(writerSettled).toBe(false);
104+
Atomics.store(coordination, 0, 0);
105+
Atomics.notify(coordination, 0);
106+
expect(await writer).toMatchObject({ inserted: true });
107+
} finally {
108+
Atomics.store(coordination, 0, 0);
109+
resetRawSessionOrdinalWarmupsForTest();
110+
await resetReusableWorkersForTests();
111+
db.close();
112+
cleanup();
113+
}
114+
});

‎packages/plugin/src/hooks/magic-context/raw-ordinal-warmup.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export function runSessionWarmupWorker(
195195
clearTimeout(deadline);
196196
options.signal?.removeEventListener("abort", abort);
197197
if (worker) {
198+
worker.off("online", startDeadline);
198199
activeWorkers.delete(worker);
199200
const stopped = shutdown?.close() ?? worker.terminate();
200201
worker.unref();
@@ -215,6 +216,9 @@ export function runSessionWarmupWorker(
215216
);
216217
deadline.unref?.();
217218
};
219+
const startDeadline = () => {
220+
if (!settled) arm("not-accepted", options.acceptMs ?? ACCEPT_TIMEOUT_MS);
221+
};
218222
const abort = () => finish({ failure: "stopped", detail: "stopped" });
219223
if (options.signal?.aborted) {
220224
abort();
@@ -228,7 +232,11 @@ export function runSessionWarmupWorker(
228232
workerData: { ...input, openCancellation: openCancellation.buffer },
229233
})
230234
: reuseWorker(defaultEntry(), {
231-
workerData: { ...input, openCancellation: openCancellation.buffer },
235+
workerData: {
236+
...input,
237+
job: rawWorkerJobKind(input),
238+
openCancellation: openCancellation.buffer,
239+
},
232240
}),
233241
);
234242
shutdown = createWorkerShutdown(worker, "raw-ordinal-warmup", WARMUP_CLOSE_DEADLINE_MS);
@@ -239,7 +247,10 @@ export function runSessionWarmupWorker(
239247
// Never keep the host process alive for a warm-up.
240248
worker.unref();
241249
activeWorkers.set(worker, (failure) => finish({ failure, detail: "stopped" }));
242-
arm("not-accepted", options.acceptMs ?? ACCEPT_TIMEOUT_MS);
250+
// Pool wait time is not thread startup. Arm the startup watchdog only
251+
// when this job is dispatched to its thread, not while it is queued.
252+
if (options.entry) startDeadline();
253+
else worker.once("online", startDeadline);
243254
worker.on("message", (reply: RawOrdinalWarmupReply) => {
244255
if (reply.kind === "accepted") arm("no-result", options.resultMs ?? RESULT_TIMEOUT_MS);
245256
else if (reply.kind === "scan") finish({ scan: reply.scan });
@@ -263,6 +274,15 @@ export function runSessionWarmupWorker(
263274
});
264275
}
265276

277+
function rawWorkerJobKind(input: RawOrdinalWarmupInput): string {
278+
if (input.indexMessage) return "index-message";
279+
if (input.ftsRowidBackfill) return "fts-rowid-backfill";
280+
if (input.searchArchive) return "search-archive";
281+
if (input.exactOrdinalIds) return "exact-ordinal-ids";
282+
if (input.tagRatio !== undefined) return input.tagState ? "tag-delta" : "tag-summary";
283+
return "ordinal-scan";
284+
}
285+
266286
/**
267287
* Prepare exact message numbers, excluding finished compaction summaries, without
268288
* scanning the session on the host thread. The worker records prefix counts

‎packages/plugin/src/shared/reusable-worker.fixture.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ serveWorkerJobs<{
77
late?: boolean;
88
exitCode?: boolean;
99
value?: string;
10+
shared?: SharedArrayBuffer;
1011
}>(async (input, port) => {
1112
if (input.crash) throw new Error("injected worker crash");
1213
if (input.hang) return await new Promise<void>(() => {});
1314
if (input.exitCode) process.exitCode = 1;
15+
if (input.shared) {
16+
const state = new Int32Array(input.shared);
17+
port.postMessage({ threadId, shared: Atomics.load(state, 0) });
18+
await new Promise<void>((resolve) =>
19+
port.once("message", () => {
20+
port.postMessage({ threadId, shared: Atomics.add(state, 0, 1) });
21+
resolve();
22+
}),
23+
);
24+
port.close();
25+
return;
26+
}
1427
if (input.late)
1528
setTimeout(() => {
1629
port.postMessage({ value: "stale" });

‎packages/plugin/src/shared/reusable-worker.test.ts‎

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { describe, expect, test } from "bun:test";
22
import { spawnSync } from "node:child_process";
33
import { once } from "node:events";
4-
import { writeFileSync } from "node:fs";
4+
import { readFileSync, writeFileSync } from "node:fs";
55
import { join } from "node:path";
6-
import type { Worker } from "node:worker_threads";
6+
import { fileURLToPath } from "node:url";
7+
import { MessageChannel, type Worker } from "node:worker_threads";
78
import { reuseWorker } from "./reusable-worker";
89
import { withTestTempDir } from "./test-temp-dir";
910

@@ -107,6 +108,21 @@ describe("reusable job workers", () => {
107108
expect((await result(job)).value).toBe("submitted");
108109
});
109110

111+
test("job input keeps shared coordination live in both directions", async () => {
112+
const state = new Int32Array(new SharedArrayBuffer(4));
113+
Atomics.store(state, 0, 1);
114+
const worker = start(`shared-${++key}`, { shared: state.buffer });
115+
const first = once(worker, "message");
116+
const exit = once(worker, "exit");
117+
expect((await first)[0].shared).toBe(1);
118+
const changed = once(worker, "message");
119+
Atomics.store(state, 0, 7);
120+
worker.postMessage({ read: true });
121+
expect((await changed)[0].shared).toBe(7);
122+
await exit;
123+
expect(Atomics.load(state, 0)).toBe(8);
124+
});
125+
110126
test("a nonzero job exit fails the lease and replaces the thread", async () => {
111127
const path = `exit-code-${++key}`;
112128
const first = await result(start(path));
@@ -139,43 +155,99 @@ describe("reusable job workers", () => {
139155

140156
test("a file ending with queued jobs cannot interrupt the next parallel file", () => {
141157
withTestTempDir("reusable-worker-files-", (dir) => {
158+
const outcomes = join(dir, "outcomes.jsonl");
159+
writeFileSync(outcomes, "");
142160
writeFileSync(
143161
join(dir, "01-owner.test.ts"),
144162
`import { test } from "bun:test";
163+
import { appendFileSync } from "node:fs";
145164
import { reuseWorker } from ${JSON.stringify(new URL("./reusable-worker.ts", import.meta.url).href)};
146165
test("owner finishes with queued work", async () => {
147166
const entry = new URL(${JSON.stringify(entry.href)});
148167
const active = reuseWorker(entry, { workerData: { path: "file-exit", hang: true } });
149-
const queued = reuseWorker(entry, { workerData: { path: "file-exit", value: "queued" } });
150168
active.on("error", () => {});
151-
queued.on("error", () => console.log("queued failure reached client"));
152-
queued.on("exit", (code) => console.log("queued exit:", code));
169+
for (let id = 0; id < 3; id++) {
170+
const queued = reuseWorker(entry, { workerData: { path: "file-exit", value: "queued" } });
171+
let replied = false;
172+
let failed = false;
173+
queued.on("message", () => { replied = true; });
174+
queued.on("error", () => { failed = true; });
175+
queued.on("exit", code => appendFileSync(${JSON.stringify(outcomes)}, JSON.stringify({
176+
id, outcome: code === 0 && replied && !failed ? "completed" : "failed",
177+
}) + "\\n"));
178+
}
153179
await Promise.resolve();
154180
});`,
155181
);
156-
// Top-level await lets the old file's native Worker exit handler run
157-
// while the next file is importing.
158182
writeFileSync(
159183
join(dir, "02-next.test.ts"),
160184
`import { describe, expect, test } from "bun:test";
185+
import { readFileSync } from "node:fs";
161186
await new Promise(resolve => setTimeout(resolve, 50));
162187
describe("next file", () => {
163-
test("still registers", () => expect(1).toBe(1));
188+
test("still registers after every queued client settles once", () => {
189+
const outcomes = readFileSync(${JSON.stringify(outcomes)}, "utf8").trim().split("\\n").filter(Boolean).map(line => JSON.parse(line));
190+
expect(outcomes).toHaveLength(3);
191+
for (let id = 0; id < 3; id++) {
192+
const client = outcomes.filter(outcome => outcome.id === id);
193+
expect(client).toHaveLength(1);
194+
expect(["completed", "failed"]).toContain(client[0].outcome);
195+
}
196+
});
164197
});`,
165198
);
166-
const child = spawnSync(process.execPath, ["test", "--parallel=1", dir], {
167-
cwd: dir,
168-
encoding: "utf8",
169-
timeout: 5_000,
170-
windowsHide: true,
171-
});
199+
// The real preload owns file teardown. Bun may discard a bare file's
200+
// callbacks without notifying its workers' clients during VM teardown.
201+
const child = spawnSync(
202+
process.execPath,
203+
[
204+
"test",
205+
"--parallel=1",
206+
"--preload",
207+
fileURLToPath(new URL("../../test-preload.ts", import.meta.url)),
208+
dir,
209+
],
210+
{ cwd: dir, encoding: "utf8", timeout: 5_000, windowsHide: true },
211+
);
172212
const output = child.stdout + child.stderr;
173213
expect(output).toContain("2 pass");
174214
expect(output).toContain("0 fail");
175215
expect(output).not.toContain("Unhandled error");
176-
expect(output).toContain("queued failure reached client");
177-
expect(output).toContain("queued exit: 1");
178216
expect(child.status).toBe(0);
217+
const settled = readFileSync(outcomes, "utf8").trim().split("\n").filter(Boolean);
218+
expect(settled).toHaveLength(3);
179219
});
180220
}, 10_000);
221+
222+
test("replacement construction failure settles every queued client exactly once", async () => {
223+
const path = `replacement-failure-${++key}`;
224+
const { port1, port2 } = new MessageChannel();
225+
try {
226+
const crashed = reuseWorker(entry, {
227+
workerData: { path, crash: true },
228+
transferList: [port1],
229+
});
230+
crashed.on("error", () => {});
231+
const clients = [start(path), start(path)].map((worker) => {
232+
const errors: unknown[] = [];
233+
const exits: number[] = [];
234+
worker.on("error", (error) => errors.push(error));
235+
const done = new Promise<void>((resolve) =>
236+
worker.on("exit", (code) => {
237+
exits.push(code);
238+
resolve();
239+
}),
240+
);
241+
return { errors, exits, done };
242+
});
243+
await Promise.all(clients.map((client) => client.done));
244+
await new Promise((resolve) => setImmediate(resolve));
245+
for (const client of clients) {
246+
expect(client.errors).toHaveLength(1);
247+
expect(client.exits).toEqual([1]);
248+
}
249+
} finally {
250+
port2.close();
251+
}
252+
});
181253
});

0 commit comments

Comments
 (0)