diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 567404092abb..728cc0fdbe31 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -4132,8 +4132,15 @@ extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObjec gcUnprotect(globalObject); globalObject = nullptr; vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); - vm.derefSuppressingSaferCPPChecking(); - vm.derefSuppressingSaferCPPChecking(); + // Drop every remaining VM ref so ~VM -> Heap::lastChanceToFinalize() + // destroys cells collectNow left conservatively reachable (whose native + // m_ctx would otherwise leak). Two refs are the creation pair; any beyond + // that are RefPtr in JSLockHolders still on the stack (e.g. + // JSEventListener::handleEvent when process.exit() came from + // worker.onmessage). global_exit is noreturn so their dtors never run, + // and workers were already joined, so no other thread holds the VM. + for (unsigned refs = vm.refCount(); refs > 0; --refs) + vm.derefSuppressingSaferCPPChecking(); runLoop->threadWillExit(); } diff --git a/test/js/web/timers/timer-heap-exit-onmessage-fixture.ts b/test/js/web/timers/timer-heap-exit-onmessage-fixture.ts new file mode 100644 index 000000000000..41353e218aa7 --- /dev/null +++ b/test/js/web/timers/timer-heap-exit-onmessage-fixture.ts @@ -0,0 +1,25 @@ +// process.exit() from inside worker.onmessage: JSEventListener::handleEvent +// has a JSLockHolder (RefPtr) on the stack, so the VM refcount is one +// higher than the two creation refs destructOnExit used to release. ~VM() and +// therefore Heap::lastChanceToFinalize() never ran, and any wrapper collectNow +// left conservatively reachable leaked its native m_ctx. Keep a Timeout +// wrapper live via globalThis so conservative reachability is deterministic. +declare var self: Worker; + +if (!Bun.isMainThread) { + self.onmessage = () => postMessage("ready"); +} else { + const worker = new Worker(import.meta.url); + worker.onerror = (e: ErrorEvent) => { + console.error("worker error:", e.message); + process.exit(1); + }; + worker.onmessage = () => { + // Rooted via globalThis so collectNow()'s mark phase keeps the JSTimeout + // alive regardless of stack layout; only lastChanceToFinalize frees it. + (globalThis as any).__pin = setTimeout(() => {}, 1 << 30); + console.log("OK"); + process.exit(0); + }; + worker.postMessage({}); +} diff --git a/test/js/web/timers/timer-heap-race.test.ts b/test/js/web/timers/timer-heap-race.test.ts index 9afea163eab9..c3dbeb7d6687 100644 --- a/test/js/web/timers/timer-heap-race.test.ts +++ b/test/js/web/timers/timer-heap-race.test.ts @@ -62,3 +62,24 @@ it.skipIf(!isASAN)( }, 20_000, ); + +it.skipIf(!isASAN)( + "process.exit() from worker.onmessage finalizes the JSC heap", + async () => { + // handleEvent's JSLockHolder leaves an extra RefPtr on the stack at + // destructOnExit, so dropping only the two creation refs left ~VM (and + // lastChanceToFinalize) unreached and conservatively-live m_ctx leaked. + const { stdout, stderr, signal, exitCode } = await runFixture("timer-heap-exit-onmessage-fixture.ts", { + BUN_DESTRUCT_VM_ON_EXIT: "1", + ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1", + LSAN_OPTIONS: `malloc_context_size=30:print_suppressions=0:suppressions=${path.join(import.meta.dir, "..", "..", "..", "leaksan.supp")}`, + }); + expect({ stdout, stderr, signal, exitCode }).toEqual({ + stdout: "OK\n", + stderr: expect.not.stringContaining("LeakSanitizer"), + signal: null, + exitCode: 0, + }); + }, + 20_000, +);