Skip to content

install: wait for the isolated installer's pool tasks before it is freed - #39640

Open
robobun wants to merge 1 commit into
mainfrom
farm/96430ae8/isolated-install-drain-tasks
Open

install: wait for the isolated installer's pool tasks before it is freed#39640
robobun wants to merge 1 commit into
mainfrom
farm/96430ae8/isolated-install-drain-tasks

Conversation

@robobun

@robobun robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • BUN-4960: bun install --linker=isolated segfaults in Installer::is_task_blocked (Segmentation fault at address 0x807). The report's feature bits show Global::exit never ran, so install_isolated_packages returned while a pool task still read the installer.
  • Installer and Store are locals of that function (src/install/isolated_install.rs) and every task reads them through Task::installer. No return path waited for the tasks. An early return under ASAN gives a heap-use-after-free in a pool thread (Notes).
  • The exact way the released build left the function is still unknown. The candidates are a pending-task slot released twice and the ? returns after tasks were started.

Fix

  • Each Task embeds a thread_pool::CountedTask on a WaitGroup that the function owns. start_task adds one, the pool finishes it when Task::callback returns, and Installer::drop waits for it. Every way out of the function now drains the pool before the installer and the store are freed. On the normal path the wait returns at once.
  • Under CI_ASSERT (on in canary) Installer.released records each entry whose slot was released. A second release, or a start after it, panics with the entry id instead of leaving a later segfault. This names the unknown trigger if it happens again.
  • Verified by fault injection only (Notes). The drain has no behavior a test can observe from outside: a release build reaches it only through the unknown trigger or an allocation failure. The hang that an earlier version of this PR also fixed is now install: fail instead of hanging when a tarball download task cannot be created #39672, with its tests.
  • Touches the same start_task lines as Project thread-pool batch tasks out of the object's pointer, not a reference #37894. Whichever lands second needs a trivial rebase.

Background

  • The isolated installer builds one Store entry per package variant and one Task per entry. Tasks run on the shared thread pool and report back through Installer.task_queue.
  • The main thread holds one pending-task slot per entry and leaves the loop when the count is zero. A slot released twice lets it leave while a task still runs.
  • CountedTask (src/threading/ThreadPool.rs) wraps a task callback and finishes a WaitGroup after the callback returns. WaitGroup::wait returns only after that finish is done with the group, so the waiter may free everything afterwards.
  • Store::drop already assumed that no task was running. Installer::drop makes it true. The installer borrows both the store and the group, so it is dropped before them.
Notes

What the report says. Trace string li252bf09c...: one frame, is_task_blocked, fault address 0x807. The packed features decode to binlinks, bunfig, extracted_packages, text_lockfile, isolated_bun_install. Bit 50 (exited, set by Global::exit) is clear, and so are lifecycle_scripts and git_dependencies. On Linux Global::exit is quick_exit, which keeps the stack intact, so the exit paths cannot free the store either way. The dependencies column of a store entry is always a built Vec (build_store appends whole entries, Dependencies::EMPTY is Vec::new()), so a lazily initialised list is not the cause. What remains is the driver leaving the function. Every ? return after the start loop is reachable only on allocation failure (get_or_put, package_patch_info, and build_url, whose string store falls back to the heap for long URLs), which is also why no test can reach the drain from outside. I did not find a double release by reading, or by running the install suites and a download failure stress with a per-entry state assertion, which is why the assertion ships in canary builds.

Fault injection. I temporarily added return Err(AllocError) after the start loop and ran a 53 package isolated install with a warm cache on the ASAN build. Without the drain: AddressSanitizer: heap-use-after-free ... READ of size 8 ... thread T5 (Bun Pool 1) at ThreadPool.rs:1249 (the task callback), freed by drop_glue::<Installer> in install_isolated_packages, in 4 of 5 runs (one of them a 4 byte read, a step load). With the CountedTask drain: nothing in 6 of 6 runs. The injection is not part of this PR.

Re-scheduling. A task that reports Blocked or RunScripts is scheduled again later by start_task, which adds to the group before the previous finish may have run. That is an add racing a finish on a count of at least one, which WaitGroup supports. wait() is only called from drop, after the last start_task. The Yield::Yield arm (the unreachable Blocked step) returns from the callback like every other arm, so its count is finished too.

Possible follow-up, not in this PR. A check in Wait::is_done that fails entries whose download callbacks can no longer arrive would turn the next accounting drift into an exit 1 instead of a hang.

Suites run with the debug build: isolated-install (82), isolated-relink, public-hoist-pattern, bun-workspaces, bun-install-lifecycle-scripts (3 failures that need node on PATH, same before the change), test/internal/source-lints. cargo clippy -p bun_install and cargo fmt are clean. A stress of 53 entries with 404, 500 and dropped tarball downloads found no crash or assertion.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Scope reduced to the drain.

The hang fix that was bundled here at first is now #39672, with the tests. This PR keeps the drain (CountedTask + WaitGroup, waited for in Installer::drop) and the canary assertion on double releases. Verified by fault injection on the ASAN build: heap-use-after-free in a pool thread without the change, clean with it. No test can reach the drain from outside (see the Notes in the description), so this ships without one. The exact trigger of the Sentry report is still open.

CI: the only red lane on the latest build is test/js/node/http2/h2-conformance.test.ts on darwin aarch64, which this diff does not touch. It is reported separately.

@coderabbitai

coderabbitai Bot commented Aug 19, 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: Pro

Run ID: e7f3c576-007c-4a49-94ef-02c2b1124856

📥 Commits

Reviewing files that changed from the base of the PR and between a356964 and 0f9ad83.

📒 Files selected for processing (5)
  • src/install/PackageManager/PackageManagerEnqueue.rs
  • src/install/isolated_install.rs
  • src/install/isolated_install/Installer.rs
  • src/install/isolated_install/Store.rs
  • test/cli/install/isolated-install.test.ts

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


Walkthrough

The change adds explicit cleanup for failed download task generation and coordinates isolated-install task lifetimes with WaitGroup. It centralizes release tracking, waits for worker callbacks during installer destruction, and adds a regression test for invalid tarball URLs.

Changes

Isolated install reliability

Layer / File(s) Summary
Download task failure cleanup
src/install/PackageManager/PackageManagerEnqueue.rs
Tarball task generation now handles Result values explicitly. Failed generation removes queued callbacks, records failed task IDs, and returns the original error.
Installer task lifetime coordination
src/install/isolated_install.rs, src/install/isolated_install/Installer.rs, src/install/isolated_install/Store.rs
The installer tracks released entries and in-flight callbacks with WaitGroup. Pending-task release uses centralized validation, and Installer::drop waits for callbacks before freeing related state.
Invalid tarball regression coverage
test/cli/install/isolated-install.test.ts
The test verifies that an invalid FTP tarball URL reports an enqueue error and exits with status 1 instead of hanging.

Possibly related PRs

  • oven-sh/bun#37669: Both changes modify isolated-install enqueueing and lifecycle code.
  • oven-sh/bun#38986: Both changes modify tarball dependency enqueueing in PackageManagerEnqueue.rs.

Suggested reviewers: jarred-sumner

🚥 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 and concisely describes the primary fix: waiting for isolated installer pool tasks before freeing the installer.
Description check ✅ Passed The description explains the problem, implementation, verification, regression coverage, and known environment-dependent test failures in sufficient detail.

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

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review raised no items. The red claude-find-issues check is the duplicate-PR action itself failing to run (it exits with is_error after one turn), not a finding about this diff. Waiting on the Buildkite build.

@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 changes cross-thread lifetime management in the isolated installer (WaitGroup ordering, raw-pointer accesses that must not outlive finish_raw, and a Drop that blocks), and the exact trigger of the Sentry crash is still open per the notes, a human look would be worthwhile.

What was reviewed:

  • WaitGroup::finish_raw/wait semantics vs. the ordering in Task::callbackfinish_raw's unlock is the last access, and manager_ptr outlives the installer, so the post-finish wake_raw is safe.
  • The hoisted wake_raw for Yield::Yield — a spurious wake, and finish_raw there prevents drop from blocking on the unreachable arm.
  • forget_failed_download: has_created_network_task inserts the dedupe entry before for_tarball can fail, so mark_network_task_failed finds it and the second entry gets AlreadyFailed.
  • The released bitset is written unconditionally but only asserted under CI_ASSERT; release_pending_task is reached only from on_task_complete/on_task_fail, so blocked-then-resumed entries don't trip the start_task check.
Extended reasoning...

Overview

This PR fixes a use-after-free class in the isolated installer (bun install --linker=isolated): pool tasks read the stack-local Installer/Store after install_isolated_packages returned. It adds a WaitGroup counted in start_task and finished in Task::callback, with Installer::drop waiting on it so every return path drains the pool first. It also fixes a related hang where a failed generate_network_task_for_tarball left the caller's context queued under the task id, so a second store entry for the same package waited forever; forget_failed_download now removes the queue entry and marks the dedupe entry failed. A per-entry released bitset adds CI_ASSERT diagnostics for double-release. Five files touched: three in src/install/, one comment update in Store.rs, and a new test in isolated-install.test.ts.

Security risks

None identified. This is internal lifetime bookkeeping in the package manager; no user input parsing, auth, or crypto is touched. The forget_failed_download path narrows the state left behind on an existing error return.

Level of scrutiny

High. Per the repo's REVIEW.md, native memory safety is the most-blocked category, and this change sits precisely there: cross-thread raw-pointer lifetime enforced by a WaitGroup, a Drop impl that blocks, ordering between task_queue.push, finish_raw, and wake_raw, and refactoring that moves per-arm wake_raw calls to a single post-match call. I traced the WaitGroup implementation (finish_raw publishes 0 under the mutex, so wait() cannot return before its unlock) and confirmed manager_ptr is captured before finish_raw and points at the longer-lived PackageManager. I checked that has_created_network_task inserts into network_dedupe_map before the fallible for_tarball, so mark_network_task_failed finds the entry. The reasoning in the PR's Notes about the Yield::Yield arm and add/finish ordering matches the code.

Other factors

The PR description is unusually thorough (ASAN repro with fault injection, 5/5 vs 4/5 counts, ordering analysis, full suite results) and the new test hangs on the released bun. The bug hunting system found nothing. Still, the author states the exact trigger of BUN-4960 is open and the CI_ASSERT bitset is there to name it next time — this is defensive hardening around an unproven root cause in concurrency-critical code, which is exactly where a second pair of human eyes on the Drop-blocks-on-WaitGroup design and the finish_rawwake_raw ordering is worth having.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

The points in the review match the intended design: finish_raw is the last access to the installer from a worker, wake_raw after it only touches the PackageManager, and release_pending_task is only reached from on_task_complete and on_task_fail, so a blocked entry that is resumed does not trip the start_task check. One correction to the summary: the released bit is set in every build, but that is one bit per entry; only the assertions are gated on CI_ASSERT.

The request for a human look on the Drop wait and the callback ordering stands. @dylan-conway asked for this change and is assigned.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:31 AM PT - Aug 19th, 2026

@robobun, your commit 7422bbc has 1 failures in Build #101345 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 39640

That installs a local version of the PR into your bun-39640 executable, so you can run:

bun-39640 --bun

Comment thread src/install/isolated_install.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
The isolated installer and its store live on the stack of
install_isolated_packages. Tasks on the thread pool read both through
Task::installer. Nothing waited for those tasks on the way out of the
function, so a return while a task was still running let the task read
freed memory.

Each task is now a CountedTask on a WaitGroup that the function owns.
start_task adds one, the pool finishes it when the task callback
returns, and Installer::drop waits for it, so every way out of the
function drains the pool before the installer and the store are freed.

Under CI_ASSERT the installer also records which entries released
their pending-task slot, and panics when an entry releases it twice or
is started after releasing it. Either one lets the install loop finish
while a task is still running.
@robobun
robobun force-pushed the farm/96430ae8/isolated-install-drain-tasks branch from fb3ba26 to 7422bbc Compare August 19, 2026 18:13
Comment thread src/install/isolated_install/Installer.rs
Comment thread src/install/isolated_install/Installer.rs
@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Restructured after a second look at the diff:

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/install/isolated_install/Installer.rs:136-145 — The PR description claims a new test in test/cli/install/isolated-install.test.ts and a fix to enqueue_package_for_download / enqueue_tarball_for_download in PackageManagerEnqueue.rs, but neither is in the diff — only the 3 isolated_install* .rs files changed. The CodeRabbit timeline shows an earlier push (0f9ad83) processed 5 files including PackageManagerEnqueue.rs; they appear to have been dropped in the force-push to fb3ba26. Please restore the dropped test and enqueue fix (or correct the description and add a test for the WaitGroup/Drop change) — REVIEW.md requires every behavioral change to ship an automated test in the same PR.

    Extended reasoning...

    What is missing

    The PR description makes two concrete claims that are not reflected in the diff:

    1. "Verified: test/cli/install/isolated-install.test.ts (the new test hangs before the fix)" — implies a new test was added to that file.
    2. "The two enqueue_*_for_download functions drop the queued context and mark the task id failed. Later entries get AlreadyFailed, which every caller already releases." — describes a change to src/install/PackageManager/PackageManagerEnqueue.rs.

    Neither file appears in the diff. git diff --name-only 0a4e3b1e..fb3ba266 lists exactly three files:

    src/install/isolated_install.rs
    src/install/isolated_install/Installer.rs
    src/install/isolated_install/Store.rs
    

    git log on both src/install/PackageManager/PackageManagerEnqueue.rs and test/cli/install/isolated-install.test.ts shows their last change was commit 92fa5e49 (PR #39558), not this PR.

    Evidence the changes were dropped, not never-written

    The CodeRabbit review timeline records two review passes:

    • First pass (a356964..0f9ad83): "Files selected for processing (5)" — including src/install/PackageManager/PackageManagerEnqueue.rs.
    • Second pass (0f9ad83..fb3ba26): "Files selected for processing (2)" — only isolated_install.rs and Installer.rs.

    Commit 0f9ad83 no longer exists in the repo. This indicates the branch was force-pushed and the intermediate commit that carried the enqueue fix and test was lost — the description was written against 0f9ad83 and never updated.

    Why this blocks merge

    (a) No test ships. REVIEW.md is explicit: "Every behavioral change ships an automated test in the same PR. 'Verified manually', unnamed 'existing tests', and benchmarks don't count, even for one-liners." This PR changes concurrent-teardown behavior (adds a WaitGroup, a Drop impl that blocks, and a released bitset with CI assertions) and ships zero new tests. The description's Notes even outline exactly the test that was intended (two workspaces depending on peer-deps@1.0.0, ftp:// tarball URL, hangs on release bun / exits 1 with the fix) — but it isn't in the diff.

    (b) The described hang fix is not applied. The description's Problem section says: "when enqueue_package_for_download cannot create the network task, the entry's context stays queued under the task id. A second store entry of the same package waits on it forever, so bun install hangs." And the Fix section says the two enqueue_*_for_download functions were changed to drop the queued context and mark the task id failed. Since PackageManagerEnqueue.rs is unchanged, that hang is not fixed by what merges here — yet the description states it is. This directly conflicts with CLAUDE.md rule 11 ("NEVER overstate what you got done").

    (c) The PR title/description no longer match the diff. Anyone reading the merged commit will believe the InvalidURL-enqueue hang is fixed when it is not.

    How to fix

    Either:

    • Restore the dropped changes: the PackageManagerEnqueue.rs hunk (drop the queued context and mark the task id failed on enqueue failure) and the new test case in test/cli/install/isolated-install.test.ts that reproduces the hang; or
    • Remove the enqueue-hang claims from the description, and add a test that covers what does ship — the WaitGroup/Installer::drop wait and the released double-release assertion (e.g. an isolated install that hits an early-return path with tasks in flight, verified clean under ASAN as the Notes describe).

    Step-by-step proof

    1. git rev-parse fb3ba266^0a4e3b1e (the PR's base is on main; this is a single-commit PR).
    2. git diff --name-only 0a4e3b1e..fb3ba266 → only the 3 isolated_install* .rs files.
    3. git log -1 --oneline -- src/install/PackageManager/PackageManagerEnqueue.rs92fa5e49 (#39558) — this PR did not touch it.
    4. git log -1 --oneline -- test/cli/install/isolated-install.test.ts92fa5e49 (#39558) — this PR did not touch it.
    5. PR description, Fix bullet 3: "The two enqueue_*_for_download functions drop the queued context and mark the task id failed." — no such hunk exists in the diff.
    6. PR description, Fix bullet 4: "test/cli/install/isolated-install.test.ts (the new test hangs before the fix)" — no test hunk exists in the diff.
    7. CodeRabbit timeline: earlier review at 0f9ad83 processed 5 files including PackageManagerEnqueue.rs; final review at fb3ba26 processed 2. Commit 0f9ad83 is gone → force-push dropped it.
    8. REVIEW.md: "Every behavioral change ships an automated test in the same PR" — violated.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

That review ran against the description from before the split. The current description matches the diff: the enqueue fix and its two tests are #39672 (where the fix also moved one level down, into generate_network_task_for_tarball), and this PR is only the drain plus the canary assertion.

This PR ships without a test on purpose, and the description says so. Every return out of install_isolated_packages after tasks have started is reachable only on allocation failure, so nothing outside the process can make the drain observable. The evidence is the fault injection in the Notes: heap-use-after-free in a pool thread in 4 of 5 ASAN runs without the drain, none in 6 runs with it. Whether that is enough to land it is the maintainer's call.

@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 changes concurrent lifetime management (a blocking Drop on Installer that waits on pool workers reading the same struct through BackRef), and ships without an automated test by design, a human look is still worthwhile — which the author has already requested.

What was reviewed:

  • CountedTask::run_and_finish reads run/group before calling Task::callback, so the worker's only post-callback access is WaitGroup::finish_raw on the stack-local group — safe to free the installer once wait() returns.
  • CountedTask.task is at offset 0 (const _ assert), so from_field_ptr!(Task, task, ...) still recovers the outer Task from the inner thread_pool::Task pointer.
  • add_one()schedule() in start_task has no fallible call between them, so the count cannot leak and hang wait(); re-scheduled Blocked/RunScripts tasks add before the prior finish, which WaitGroup supports.
  • release_pending_task is only reached from on_task_complete/on_task_fail (main thread), so a resumed blocked entry does not trip the start_task released assertion.
Extended reasoning...

Overview

This PR hardens the isolated installer against a use-after-free where install_isolated_packages returns (via ? on allocation failure or an unidentified accounting drift) while thread-pool tasks still read the stack-local Installer/Store through Task::installer. The fix wraps each pool task in the existing thread_pool::CountedTask on a caller-owned WaitGroup, adds Installer::drop that waits on the group, and centralizes pending-task release into release_pending_task with a CI_ASSERT-gated double-release check backed by a per-entry released bitset. Three files touched: isolated_install.rs (group + task construction), Installer.rs (Drop, start_task, release_pending_task), Store.rs (SAFETY comment update only).

Security risks

None. This is internal lifetime coordination in the package installer; no user-facing surface, parsing, auth, or crypto is touched. The change strictly narrows the window in which pool workers may observe freed memory.

Level of scrutiny

High. Per the repo review guide, native memory-safety and thread-affinity changes are the most-blocked category. This PR adds a blocking Drop and relies on several ordering invariants: declaration order of tasks_in_flight vs. installer (borrow-checker-enforced, since Installer<'a> borrows both &store and &tasks_in_flight), CountedTask's offset-0 layout so from_field_ptr! still works, and WaitGroup::finish_raw's contract that the group may be freed once wait() returns. I verified each of these against src/threading/ThreadPool.rs and src/threading/WaitGroup.rs and found them sound, and confirmed no fallible call sits between add_one() and schedule() in start_task.

Other factors

The PR ships without an automated test — the author explains the drain is only reachable via allocation failure or an unknown Sentry-reported trigger, and verified via ASAN fault injection (4/5 UAF without, 0/6 with). REVIEW.md normally requires a test in the same PR; whether fault-injection-only verification is acceptable here is a maintainer call. The author has explicitly requested a human look on the Drop wait and callback ordering, and dylan-conway is assigned. There is also a stated overlap with #37894 on the start_task lines. Given all of that, deferring rather than approving.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

The re-review against the current diff clears the earlier finding about the description, and it confirms the four invariants this change rests on (finish after the callback returns, CountedTask at offset 0, no fallible call between add_one and schedule, releases only from on_task_complete and on_task_fail). Nothing is outstanding on my side. The remaining question is the one for a maintainer: whether the fault injection in the description is enough to land this without a test.

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