From 7146dd70da7894c71fc2cdb5a8b01f15a59a89d5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:14:10 +0000 Subject: [PATCH 1/2] Fix dangling PackageManager.log when JS runs during auto-install sleep_until enqueue_dependency_to_root blocks in sleep_until, which ticks the JS event loop between run_tasks polls. Module transpilation that runs in that tick saves transpiler.log and restores pm.log to it on exit. The resolve path did not swap transpiler.log, so this restore pointed pm.log away from the resolve's scoped log. The next run_tasks poll then wrote to the wrong Log, and with further interleaving to a dead stack Log. Swap and restore transpiler.log with the other log pointers in resolve_maybe_needs_trailing_slash. Snapshot pm.log on entry to sleep_until and re-assert it before each run_tasks poll. The regression test queues require() calls with setImmediate so they run during the tick. Without the fix the 404 diagnostics land in the VM log and print to stderr at exit. --- .../PackageManager/PackageManagerEnqueue.rs | 9 +++ src/jsc/VirtualMachine.rs | 5 ++ .../resolve-autoinstall-log-dangling.test.ts | 65 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index d1cd053d7602..ffcb426ad128 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -596,6 +596,13 @@ pub fn enqueue_dependency_to_root( // raw `*mut` — `sleep_until` // also receives this pointer, so `&mut` here would alias. manager: *mut PackageManager, + // `sleep_until` ticks the JS event loop between polls; code + // run there (module transpile / nested resolve / AsyncModule) + // swaps `manager.log` and may restore it from a different + // source than the caller did, leaving it pointing at a dead + // stack `Log`. Snapshot the caller's log and re-assert it + // before every `run_tasks` so `log_mut()` never dangles. + log: *mut bun_ast::Log, } impl Closure { fn is_done(&mut self) -> bool { @@ -603,6 +610,7 @@ pub fn enqueue_dependency_to_root( // below; `sleep_until`/`tick_raw` hold no `&mut` across // this callback, so this is the unique live borrow. let manager = unsafe { &mut *self.manager }; + manager.log = self.log; if manager.pending_task_count() > 0 { // All callbacks void: `VoidRunTasksCallbacks` (below) // has `Ctx = ()` and every `HAS_* = false`. @@ -639,6 +647,7 @@ pub fn enqueue_dependency_to_root( let mut closure = Closure { err: None, manager: mgr, + log: this.log, }; // SAFETY: `mgr` derived from the live exclusive `this` borrow; // `sleep_until` + `tick_raw` hold no `&mut PackageManager` across diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index e1171eb8df47..a55669eae574 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4734,8 +4734,10 @@ impl VirtualMachine { // so the `Option` is purely a // zeroed-init nicety; the `expect` is infallible. let old_log: NonNull = jsc_vm.log.expect("vm.log set in init"); + let old_transpiler_log: *mut bun_ast::Log = jsc_vm.transpiler.log; let mut log = bun_ast::Log::default(); jsc_vm.log = NonNull::new(&raw mut log); + jsc_vm.transpiler.log = &raw mut log; jsc_vm.transpiler.resolver.log = NonNull::from(&mut log); jsc_vm.transpiler.linker.log = &raw mut log; if let Some(pm) = jsc_vm.transpiler.resolver.package_manager { @@ -4752,6 +4754,7 @@ impl VirtualMachine { struct RestoreLog { vm: bun_ptr::BackRef, old_log: NonNull, + old_transpiler_log: *mut bun_ast::Log, } impl Drop for RestoreLog { fn drop(&mut self) { @@ -4759,6 +4762,7 @@ impl VirtualMachine { // thread); `old_log` outlives the VM (Box::leak in `init`). let jsc_vm = self.vm.get().as_mut(); jsc_vm.log = Some(self.old_log); + jsc_vm.transpiler.log = self.old_transpiler_log; jsc_vm.transpiler.resolver.log = self.old_log; jsc_vm.transpiler.linker.log = self.old_log.as_ptr(); // `_resolve` may have lazily created the PM with @@ -4776,6 +4780,7 @@ impl VirtualMachine { let _restore = RestoreLog { vm: bun_ptr::BackRef::from(NonNull::new(jsc_vm_ptr).expect("vm non-null")), old_log, + old_transpiler_log, }; // Note: reshaped for borrowck — re-derive from raw so the unique // borrow doesn't span the guard's drop. diff --git a/test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts b/test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts index 3cd0086aa672..a5bc1c48c844 100644 --- a/test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts +++ b/test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts @@ -57,3 +57,68 @@ test("repeated failing auto-install resolves at varying stack depth don't read a expect(stdout.trim()).toBe("ok"); expect(exitCode).toBe(0); }); + +// Auto-install's `sleep_until` ticks the JS event loop while waiting for the +// manifest response. JS that runs during that tick (module transpile) swaps +// `PackageManager.log` (and the related resolver/linker/transpiler log +// pointers) and restores them from `transpiler.log` — which the outer resolve +// hadn't swapped — instead of the resolve's scoped log. The next `run_tasks` +// poll then wrote its 404 diagnostic into the VM's persistent log, which is +// dumped to stderr at process exit. With enough interleaving across REPRL +// iterations this escalated to `pm.log` pointing at dead stack memory (ASAN +// stack-buffer-overflow). +test("module transpile during auto-install's event-loop tick doesn't desync pm.log", async () => { + let hits = 0; + await using server = Bun.serve({ + port: 0, + fetch() { + hits++; + return new Response("Not Found", { status: 404 }); + }, + }); + + using dir = tempDir("resolve-autoinstall-log-tick", { + "index.js": ` + const Module = require("module"); + const fs = require("fs"); + const path = require("path"); + + let n = 0; + function load() { + const f = path.join(import.meta.dir, "dyn" + n++ + ".cjs"); + fs.writeFileSync(f, "module.exports = 0;"); + try { require(f); } catch {} + } + + for (let i = 0; i < 20; i++) { + setImmediate(load); + setImmediate(load); + try { + Module._resolveFilename("autoinstall-missing-pkg-" + i, { filename: import.meta.path }); + } catch {} + } + + console.log("ok " + n); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--install=force", "index.js"], + cwd: String(dir), + env: { + ...bunEnv, + BUN_CONFIG_REGISTRY: server.url.href, + NPM_CONFIG_REGISTRY: server.url.href, + }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toMatch(/^ok \d+$/m); + expect(Number(stdout.trim().slice(3))).toBeGreaterThan(0); + expect(hits).toBeGreaterThan(0); + expect(exitCode).toBe(0); +}); From 1874c93272490e7a5445acce44eaa31e4f2aa038 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:16:56 +0000 Subject: [PATCH 2/2] Shorten the log-snapshot comment in enqueue_dependency_to_root --- src/install/PackageManager/PackageManagerEnqueue.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index ffcb426ad128..8a6513facceb 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -596,12 +596,9 @@ pub fn enqueue_dependency_to_root( // raw `*mut` — `sleep_until` // also receives this pointer, so `&mut` here would alias. manager: *mut PackageManager, - // `sleep_until` ticks the JS event loop between polls; code - // run there (module transpile / nested resolve / AsyncModule) - // swaps `manager.log` and may restore it from a different - // source than the caller did, leaving it pointing at a dead - // stack `Log`. Snapshot the caller's log and re-assert it - // before every `run_tasks` so `log_mut()` never dangles. + // `sleep_until` ticks the JS event loop, and JS run there can + // swap `manager.log` and leave it pointing at a dead stack + // `Log`. `is_done` re-asserts this snapshot before each poll. log: *mut bun_ast::Log, } impl Closure {