diff --git a/src/bun.js/bindings/BunWorkerGlobalScope.cpp b/src/bun.js/bindings/BunWorkerGlobalScope.cpp index b4844710fb65ea..08777ed0583ebc 100644 --- a/src/bun.js/bindings/BunWorkerGlobalScope.cpp +++ b/src/bun.js/bindings/BunWorkerGlobalScope.cpp @@ -17,24 +17,29 @@ void WorkerGlobalScope::onDidChangeListenerImpl(EventTarget& self, const AtomStr { if (eventType == eventNames().messageEvent) { auto& global = static_cast(self); + auto* context = global.scriptExecutionContext(); + // Only ref/unref the event loop if we're in a worker thread, not the main thread. + // In the main thread, onmessage handlers shouldn't keep the process alive. + bool shouldRefEventLoop = context && !context->isMainThread(); + switch (kind) { case Add: - if (global.m_messageEventCount == 0) { - global.scriptExecutionContext()->refEventLoop(); + if (global.m_messageEventCount == 0 && shouldRefEventLoop) { + context->refEventLoop(); } global.m_messageEventCount++; break; case Remove: global.m_messageEventCount--; - if (global.m_messageEventCount == 0) { - global.scriptExecutionContext()->unrefEventLoop(); + if (global.m_messageEventCount == 0 && shouldRefEventLoop) { + context->unrefEventLoop(); } break; // I dont think clear in this context is ever called. If it is (search OnDidChangeListenerKind::Clear for the impl), // it may actually call once per event, in a way the Remove code above would suffice. case Clear: - if (global.m_messageEventCount > 0) { - global.scriptExecutionContext()->unrefEventLoop(); + if (global.m_messageEventCount > 0 && shouldRefEventLoop) { + context->unrefEventLoop(); } global.m_messageEventCount = 0; break; diff --git a/test/js/web/workers/onmessage-main-thread.test.ts b/test/js/web/workers/onmessage-main-thread.test.ts new file mode 100644 index 00000000000000..c5432117bf5013 --- /dev/null +++ b/test/js/web/workers/onmessage-main-thread.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +test("setting global onmessage in main thread should not prevent process exit", async () => { + // This test verifies that setting a global onmessage handler in the main thread + // doesn't keep the event loop alive and prevent the process from exiting. + // This was a bug where packages like 'lzma' that detect Web Worker environments + // by checking `typeof onmessage !== 'undefined'` would inadvertently keep the + // process alive. + + using dir = tempDir("onmessage-test", { + "test.js": ` + // Set a global onmessage handler (simulating what the lzma package does) + onmessage = function(e) { + console.log('received message:', e); + }; + console.log('OK'); + // Process should exit here, not hang + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.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("OK"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}, 5000); // 5 second timeout - should exit quickly + +test("setting global onmessage in worker thread should work normally", async () => { + // This test verifies that onmessage in a worker thread still works correctly + // and doesn't exit prematurely. + + using dir = tempDir("onmessage-worker-test", { + "worker.js": ` + onmessage = function(e) { + postMessage('received: ' + e.data); + }; + `, + "main.js": ` + const worker = new Worker(new URL('worker.js', import.meta.url).href); + worker.postMessage('hello'); + worker.onmessage = (e) => { + console.log(e.data); + worker.terminate(); + }; + `, + }); + + 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: hello"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}, 5000);