Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
996e703f4e fix: worker parentPort.on("message") not firing with top-level await
When a worker module uses top-level await (TLA), `loadEntryPointForWebWorker`
would block in `waitForPromiseWithTermination` until the module promise settled.
For modules with infinite TLA loops (e.g. `while (true) { await ... }`), this
meant `dispatchOnline` and `fireEarlyMessages` were never reached, so messages
posted via `worker.postMessage()` were queued in `m_pendingTasks` but never
dispatched to `parentPort` listeners.

The fix changes `loadEntryPointForWebWorker` to run a single event loop tick
(executing synchronous module code including listener registration) instead of
blocking until the promise settles. The caller (`spin()`) then calls
`dispatchOnline`/`fireEarlyMessages` and enters the main event loop, which
naturally handles both TLA promise resolution and message dispatching.

Closes #15408

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 02:41:10 +00:00
3 changed files with 61 additions and 8 deletions

View File

@@ -2235,13 +2235,22 @@ pub fn reloadEntryPointForTestRunner(this: *VirtualMachine, entry_path: []const
return promise;
}
// worker dont has bun_watcher and also we dont wanna call autoTick before dispatchOnline
// Worker doesn't have bun_watcher and also we don't want to call autoTick before dispatchOnline.
// This function kicks off module evaluation and runs a single tick to execute all
// synchronous code (including event listener registration like parentPort.on("message", ...)).
// It does NOT wait for top-level await to complete — the caller (spin()) is responsible
// for calling dispatchOnline/fireEarlyMessages and then entering the main event loop,
// which will naturally resolve any pending TLA promise.
pub fn loadEntryPointForWebWorker(this: *VirtualMachine, entry_path: string) anyerror!*JSInternalPromise {
const promise = try this.reloadEntryPoint(entry_path);
_ = try this.reloadEntryPoint(entry_path);
this.eventLoop().performGC();
this.eventLoop().waitForPromiseWithTermination(jsc.AnyPromise{
.internal = promise,
});
// Run a single tick to execute synchronous module code. This ensures that
// event listeners (e.g. parentPort.on("message")) are registered before
// we call dispatchOnline/fireEarlyMessages. We intentionally do NOT wait
// for the promise to settle here, because modules with top-level await
// (e.g. `while (true) { await ... }`) may never settle, and blocking here
// would prevent messages from being dispatched to the worker.
this.eventLoop().tick();
if (this.worker) |worker| {
if (worker.hasRequestedTerminate()) {
return error.WorkerTerminated;

View File

@@ -495,14 +495,14 @@ fn spin(this: *WebWorker) void {
this.exitAndDeinit();
return;
}
} else {
} else if (promise.status() == .fulfilled) {
_ = promise.result();
}
// If promise is still pending (top-level await), that's OK — the main
// event loop below will continue to tick until it settles.
this.flushLogs();
log("[{d}] event loop start", .{this.execution_context_id});
// TODO(@190n) call dispatchOnline earlier (basically as soon as spin() starts, before
// we start running JS)
WebWorker__dispatchOnline(this.cpp_worker, vm.global);
WebWorker__fireEarlyMessages(this.cpp_worker, vm.global);
this.setStatus(.running);

View File

@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/15408
// parentPort.on("message") never fires when the worker module uses top-level await.
test("parentPort.on('message') works with top-level await in worker", async () => {
using dir = tempDir("issue-15408", {
"main.js": `
import { Worker } from "node:worker_threads";
const worker = new Worker("./worker.js", {});
worker.postMessage("hello");
worker.on("message", (msg) => {
console.log(msg);
worker.terminate();
});
`,
"worker.js": `
import { parentPort } from "node:worker_threads";
parentPort.on("message", (msg) => {
parentPort.postMessage("received message");
});
// Top-level await - the worker should still receive messages
// while this TLA is pending.
while (true) {
await new Promise((r) => setImmediate(r));
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "main.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("received message");
expect(exitCode).toBe(0);
});