|
1 | 1 | import { describe, expect, test } from "bun:test"; |
2 | 2 | import { spawnSync } from "node:child_process"; |
3 | 3 | import { once } from "node:events"; |
4 | | -import { writeFileSync } from "node:fs"; |
| 4 | +import { readFileSync, writeFileSync } from "node:fs"; |
5 | 5 | 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"; |
7 | 8 | import { reuseWorker } from "./reusable-worker"; |
8 | 9 | import { withTestTempDir } from "./test-temp-dir"; |
9 | 10 |
|
@@ -107,6 +108,21 @@ describe("reusable job workers", () => { |
107 | 108 | expect((await result(job)).value).toBe("submitted"); |
108 | 109 | }); |
109 | 110 |
|
| 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 | + |
110 | 126 | test("a nonzero job exit fails the lease and replaces the thread", async () => { |
111 | 127 | const path = `exit-code-${++key}`; |
112 | 128 | const first = await result(start(path)); |
@@ -139,43 +155,99 @@ describe("reusable job workers", () => { |
139 | 155 |
|
140 | 156 | test("a file ending with queued jobs cannot interrupt the next parallel file", () => { |
141 | 157 | withTestTempDir("reusable-worker-files-", (dir) => { |
| 158 | + const outcomes = join(dir, "outcomes.jsonl"); |
| 159 | + writeFileSync(outcomes, ""); |
142 | 160 | writeFileSync( |
143 | 161 | join(dir, "01-owner.test.ts"), |
144 | 162 | `import { test } from "bun:test"; |
| 163 | +import { appendFileSync } from "node:fs"; |
145 | 164 | import { reuseWorker } from ${JSON.stringify(new URL("./reusable-worker.ts", import.meta.url).href)}; |
146 | 165 | test("owner finishes with queued work", async () => { |
147 | 166 | const entry = new URL(${JSON.stringify(entry.href)}); |
148 | 167 | const active = reuseWorker(entry, { workerData: { path: "file-exit", hang: true } }); |
149 | | - const queued = reuseWorker(entry, { workerData: { path: "file-exit", value: "queued" } }); |
150 | 168 | 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 | + } |
153 | 179 | await Promise.resolve(); |
154 | 180 | });`, |
155 | 181 | ); |
156 | | - // Top-level await lets the old file's native Worker exit handler run |
157 | | - // while the next file is importing. |
158 | 182 | writeFileSync( |
159 | 183 | join(dir, "02-next.test.ts"), |
160 | 184 | `import { describe, expect, test } from "bun:test"; |
| 185 | +import { readFileSync } from "node:fs"; |
161 | 186 | await new Promise(resolve => setTimeout(resolve, 50)); |
162 | 187 | 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 | + }); |
164 | 197 | });`, |
165 | 198 | ); |
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 | + ); |
172 | 212 | const output = child.stdout + child.stderr; |
173 | 213 | expect(output).toContain("2 pass"); |
174 | 214 | expect(output).toContain("0 fail"); |
175 | 215 | expect(output).not.toContain("Unhandled error"); |
176 | | - expect(output).toContain("queued failure reached client"); |
177 | | - expect(output).toContain("queued exit: 1"); |
178 | 216 | expect(child.status).toBe(0); |
| 217 | + const settled = readFileSync(outcomes, "utf8").trim().split("\n").filter(Boolean); |
| 218 | + expect(settled).toHaveLength(3); |
179 | 219 | }); |
180 | 220 | }, 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 | + }); |
181 | 253 | }); |
0 commit comments