Skip to content

Fix dangling PackageManager.log when JS runs during auto-install sleep_until - #41149

Merged
Jarred-Sumner merged 2 commits into
mainfrom
robobun/78c7a039/fix-pm-log-dangling-sleep-until
Sep 2, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
robobun/78c7a039/fix-pm-log-dangling-sleep-until

Conversation

@robobun

@robobun robobun commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fuzzilli found an ASAN stack-buffer-overflow in run_tasks -> Log::add_msg -> Vec::push during runtime auto-install.

What happened

enqueue_dependency_to_root blocks in sleep_until. sleep_until ticks the JS event loop between is_done polls. Each poll calls run_tasks, which reads manager.log to log manifest 4xx errors.

The event loop tick can run module transpilation or AsyncModule::resume_loading_module. Both swap the VM's log pointers (jsc_vm.log, transpiler.log, resolver.log, linker.log, pm.log) and restore them on exit. They do not save from the same source:

  • resolve_maybe_needs_trailing_slash saves jsc_vm.log and swaps every pointer except transpiler.log.
  • transpile_source_code_inner saves transpiler.log and restores pm.log to it.
  • AsyncModule::resume_loading_module saves jsc_vm.log but restores transpiler.log to it.

When a module transpile runs during a resolve's sleep_until tick, its restore sets pm.log to the old transpiler.log, not the resolve's scoped log. The 404 diagnostics from run_tasks then land in the wrong log. With more interleaving across calls pm.log ends up at a dead stack Log, and the next run_tasks poll reads it.

READ of size 8 at 0x7ffd98a149c0 thread T0
    #0  RawVecInner::capacity
    #1  Log::add_msg (lib.rs:2369)
    #3  Log::add_error_fmt (lib.rs:2032)
    #4  run_tasks (runTasks.rs:494)
    #5  Closure::is_done (PackageManagerEnqueue.rs:534)
    #7  AnyEventLoop::tick_raw (AnyEventLoop.rs:148)
    #8  PackageManager::sleep_until (PackageManager.rs:1063)
    #9  enqueue_dependency_to_root (PackageManagerEnqueue.rs:571)
    ...
    #18 resolve_maybe_needs_trailing_slash (VirtualMachine.rs:4259)

Address is located in stack of thread T0 in frame
    #0  to_js_host_call (host_fn.rs:677)
  [144, 152) 'scope' <== Memory access at offset 160 overflows this variable
  [176, 240) 'scope_storage'

Fix

  • Swap and restore transpiler.log with the other log pointers in resolve_maybe_needs_trailing_slash. It can no longer drift from jsc_vm.log across a resolve.
  • Snapshot pm.log on entry to enqueue_dependency_to_root's sleep_until. Re-assert it before each run_tasks poll. run_tasks always sees the caller's log, whatever the event loop tick did to it.

Test

The test runs a 404 registry in the parent process on port: 0. The child queues require() calls with setImmediate, so they run during sleep_until's event-loop tick and trigger transpile_source_code_inner's log swap. Without the fix, the 404 errors land in the VM log and print to stderr at exit. With the fix, stderr is empty.

Verified on current main (6f27257): the test fails without the source change and passes with it.

Supersedes #31120, which the stale bot closed after 90 days. Same change, rebased onto current main; moved to this branch so the fix can be tracked.


[human-review] gate passed · iteration 6 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts:
(pass) repeated failing auto-install resolves at varying stack depth don't read a dangling pm.log [1075.94ms]
114 |     stderr: "pipe",
115 |   });
116 | 
117 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
118 | 
119 |   expect(stderr).toBe("");
                       ^
error: expect(received).toBe(expected)

- ""
+ "error: GET http://localhost:37987/autoinstall-missing-pkg-0 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-1 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-2 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-3 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-4 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-5 - 404
+ 
+ error: GET http://localhost:37987/autoinstall-missing-pkg-6 - 404
+ 
+ error: GET http://
... (truncated)

release without fix: 1 FAILED
bun test v1.4.1-canary.1 (a6c4cc276)

test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts:
(pass) repeated failing auto-install resolves at varying stack depth don't read a dangling pm.log [22.63ms]
114 |     stderr: "pipe",
115 |   });
116 | 
117 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
118 | 
119 |   expect(stderr).toBe("");
                       ^
error: expect(received).toBe(expected)

- ""
+ "error: GET http://localhost:35697/autoinstall-missing-pkg-0 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-1 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-2 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-3 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-4 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-5 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-6 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-7 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-8 - 404
+ 
+ error: GET http://localhost:35697/autoinstall-missing-pkg-9 - 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts:
(pass) repeated failing auto-install resolves at varying stack depth don't read a dangling pm.log [1104.72ms]
(pass) module transpile during auto-install's event-loop tick doesn't desync pm.log [689.71ms]

 2 pass
 0 fail
 8 expect() calls
Ran 2 tests across 1 file. [3.73s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 590ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/141] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 244 extern-C blocks audited
[2/141] gen JS modules (bundle-modules)
Preprocess modules (8046ms)
Bundle modules (666ms)
Postprocesss modules (969ms)
Bundle Functions (629ms)
Generate Code (40ms)

[10.36s] Bundled "src/js" for production
  2594 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/141] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_output_tags v0.0.0 (/workspace/bun/src/bun_output_tags)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_parsers v0.0.0 (/workspace/bun/src/parsers)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_dispatch v0.0.0 (/workspace/bun/src/dispatch)
�[1m�[92m   Compiling�[0m bun_jsc_macros v0.0.0 (/workspace/bun/src/jsc_macros)

... (truncated)
diff hotspot
.../PackageManager/PackageManagerEnqueue.rs        |  6 ++
 src/jsc/VirtualMachine.rs                          |  5 ++
 .../resolve-autoinstall-log-dangling.test.ts       | 65 ++++++++++++++++++++++
 3 files changed, 76 insertions(+)

gate history · 1 passed · 0 rejected · iteration 6

evidence per changed file
file                                                      reads  edits  tests
src/install/PackageManager/PackageManagerEnqueue.rs           1      2     19
src/jsc/VirtualMachine.rs                                     5      3     19
…js/bun/resolve/resolve-autoinstall-log-dangling.test.ts      3      9     18

…p_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.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 7360da7b-8030-4575-bb81-f016fc39713a

📥 Commits

Reviewing files that changed from the base of the PR and between 6f27257 and 1874c93.

📒 Files selected for processing (3)
  • src/install/PackageManager/PackageManagerEnqueue.rs
  • src/jsc/VirtualMachine.rs
  • test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The change preserves valid log pointers during module resolution and auto-install event-loop polling. A regression test covers interleaved module loads, missing-package resolution, clean stderr, successful execution, and registry requests.

Changes

Log lifetime protection

Layer / File(s) Summary
Resolution log routing and polling
src/jsc/VirtualMachine.rs, src/install/PackageManager/PackageManagerEnqueue.rs
Module resolution assigns the temporary log to the transpiler and restores it during cleanup. Auto-install polling restores the manager log before each cycle.
Regression coverage
test/js/bun/resolve/resolve-autoinstall-log-dangling.test.ts
The test interleaves setImmediate module loads with missing-package resolution and verifies clean stderr, module execution, successful completion, and registry requests.

Suggested reviewers: jarred-sumner, alii

Merge Risk: ⚪ Minimal · up to 1874c

This localized change preserves the caller’s log during auto-install event-loop processing and adds a regression test for the dangling-log failure; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary fix: preventing a dangling PackageManager.log during auto-install sleep_until. It is concise and specific.
Description check ✅ Passed The description explains the failure, root cause, fix, regression test, and verification results. It provides the information required by the repository template, although it uses equivalent headings …
Full details: Description check

Explanation

The description explains the failure, root cause, fix, regression test, and verification results. It provides the information required by the repository template, although it uses equivalent headings instead of the exact template headings.


Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it's a memory-safety fix for a dangling-pointer bug driven by subtle event-loop reentrancy across three log-swap sites, a human look would still be worthwhile — particularly on whether the is_done re-assert plus the single-site transpiler.log swap is the right shape versus aligning the save/restore sources in transpile_source_code_inner and AsyncModule::resume_loading_module as well.

What was reviewed:

  • RestoreLog guard: old_transpiler_log is saved and restored independently (not forced to old_log), so it preserves whatever the caller had — no new assumption that transpiler.log == vm.log on entry.
  • Closure.log snapshot: taken from this.log before sleep_until, re-asserted before each run_tasks; the raw *mut matches the existing manager field pattern and the SAFETY comment's aliasing story.
  • Test: local port: 0 registry, tempDir, bunEnv spread, concurrent pipe drain, stderr/stdout asserted before exit code; stderr === "" checks the observable log-desync symptom (404 diagnostics leaking to the VM log), not a generic crash string, and n > 0 / hits > 0 confirm the interleaving actually happened.
Extended reasoning...

Overview

The PR fixes an ASAN-reported stack-buffer-overflow where PackageManager.log could point at a dead stack Log after JS runs during sleep_until's event-loop tick inside auto-install resolution. Two source changes: src/jsc/VirtualMachine.rs now swaps/restores jsc_vm.transpiler.log alongside the other four log pointers in the RestoreLog drop guard, and src/install/PackageManager/PackageManagerEnqueue.rs snapshots this.log into the sleep_until closure and re-asserts manager.log = self.log at the top of every is_done() poll. A new test in the existing resolve-autoinstall-log-dangling.test.ts reproduces the interleaving with setImmediate-queued require() calls during a _resolveFilename auto-install against a local 404 registry.

Security risks

None identified. This is an internal memory-safety fix in the package-manager/resolver log-pointer plumbing; no new user-facing surface, no untrusted-input parsing, no auth/crypto/permissions paths touched. The test uses a local Bun.serve({ port: 0 }) — no external network contact.

Level of scrutiny

High. Per REVIEW.md, native memory safety is the most-blocked review category, and this change sits squarely in it: raw *mut pointers, a hand-rolled drop guard, and a fix whose correctness depends on understanding the exact save/restore behavior of three separate log-swap sites (resolve_maybe_needs_trailing_slash, transpile_source_code_inner, AsyncModule::resume_loading_module) under reentrancy. The PR description names all three but patches only one, relying on the is_done re-assert as defense-in-depth for the others. That's a defensible design — it makes run_tasks immune to whatever the tick did — but "fix the whole class in the same PR" invites a human to confirm that leaving the other two sites' asymmetric save/restore in place doesn't leave a sibling bug reachable from a different entry point that doesn't route through this closure.

Other factors

The change is small (~15 source lines), follows the file's existing raw-pointer + drop-guard patterns exactly, and the added old_transpiler_log field restores the saved value rather than forcing it to old_log — a conservative choice that avoids introducing a new invariant. The test follows harness conventions correctly (concurrent pipe drain, stderr before exit code, no sleeps, appended to the existing file for this module) and asserts the observable symptom the PR description says distinguishes fixed from unfixed builds. No CODEOWNERS entries cover the changed paths, and the timeline shows no outstanding third-party objections. The bug hunt ran to dry_streak with no findings. This supersedes a previously stale-closed PR, so prior context exists that a human reviewer may want to compare.

@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

A note on scope, for whoever reviews the shape of this change.

I kept the swap change to resolve_maybe_needs_trailing_slash on purpose. It is the only site that both (a) parks a stack Log in the shared pointers and (b) spins the JS event loop while that Log is live. The other two sites restore to whatever they saved, so once the resolve also swaps transpiler.log, a transpile that runs during the tick saves and restores the resolve's scoped log. That is the correct value.

AsyncModule::resume_loading_module is not reachable on current main. parse_result.pending_imports has no producer. Every construction site sets it to Default::default() and the only guard that would enqueue an AsyncModule (jsc_hooks.rs:3012) checks len() > 0. I did not change it.

The is_done re-assert is the part that closes the window for any future caller of sleep_until on the JS loop. run_tasks always writes to the log the caller handed the package manager, whatever ran in the tick.

@Jarred-Sumner
Jarred-Sumner merged commit d1b7fac into main Sep 2, 2026
22 of 23 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/78c7a039/fix-pm-log-dangling-sleep-until branch September 2, 2026 03:48
robobun added a commit that referenced this pull request Sep 16, 2026
On a JS thread, PackageManager::sleep_until now calls park_until: it
registers in wake_waiters, then waits on wake_count with a futex until
is_done is true. wake_raw bumps wake_count and calls Futex::wake only
when a thread is parked, so a `bun install` task pays no syscall. Both
sides use SeqCst: the waiter registers before it reads the count, the
waker bumps the count before it reads the waiters, so no wake is lost.

Since #40734, git for a git dependency is a child process on the install
thread's event loop. The parked thread cannot reap it, so the wait would
never end. The resolver cannot load a git resolution in any case
(path_for_resolution serves npm only). The auto-install manager now
leaves a git dependency unresolved and does not start git.
enqueue_git_task asserts this in debug builds.

Remove the PackageManager.log snapshot from #41149: no JS runs inside
the wait now, so nothing can swap the log pointer there. Its test now
asserts that the queued module loads run after the resolve loop.

Tests: one case per entry point (require, require.resolve,
Bun.resolveSync, import.meta.resolve, import.meta.resolveSync, import(),
Bun.resolve). A Bun.serve handler makes the call while the test holds
the manifest request until a second request is written. Two git cases
(a dependency of the project, a dependency of an auto-installed
package). A module graph that imports a builtin and then an
auto-installed package.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants