Skip to content

bundler: free the ServerComponentParseTask after it generates its file - #38004

Open
robobun wants to merge 4 commits into
mainfrom
farm/7aa977ed/free-server-component-parse-task
Open

bundler: free the ServerComponentParseTask after it generates its file#38004
robobun wants to merge 4 commits into
mainfrom
farm/7aa977ed/free-server-component-parse-task

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Every server-component file the bundler generates (the "reference proxy" it substitutes for a "use client" module when the framework uses a separate SSR graph, in dev and in bun build --app) leaks its ServerComponentParseTask. LeakSanitizer on a bake dev server that bundles one client module:
    Direct leak of 336 byte(s) in 1 object(s) allocated from:
        <alloc::boxed::Box<bun_bundler::ServerComponentParseTask::ServerComponentParseTask>>::new
        <bun_bundler::bundle_v2::BundleV2>::enqueue_server_component_generated_file  src/bundler/bundle_v2.rs:3760
        <bun_bundler::bundle_v2::BundleV2>::on_parse_task_complete
    
  • Cause: BundleV2::enqueue_server_component_generated_file boxes the task with heap::into_raw and schedules it, and nothing ever takes the box back. task_callback_wrap (src/bundler/ServerComponentParseTask.rs) only borrowed it through the intrusive task field, and parse_worker::on_complete frees the parse_task::Result, not the task. The comment at the enqueue site claimed on_complete frees it.
  • The task's payload leaked too, and freeing the box alone would not have fixed that: ReferenceProxy held a clone of the client module's Source and of its NamedExports map (bundle_v2.rs, named_exports.clone() in on_parse_task_complete). The map clone is AstAlloc-backed, and on both paths that produce these tasks no AST allocation state is installed when on_parse_task_complete runs, so it went to AstAlloc's never-freed global fallback (invisible to LSan, one more per "use client" module on every dev-server rebuild). The Zig original shared the AST's map by value; the port deep-cloned it.

Fix

  • task_callback_wrap reclaims the Box<ServerComponentParseTask> with heap::take and drops it once the generated AST has been built, before the Result is posted. The pool runs a task exactly once and does not touch it after the callback returns (ThreadPool.rs worker loop), the same contract OwnedTask::__callback and WorkPool::go rely on to free their tasks inside the callback.
  • ReferenceProxy now carries exactly what the generator reads: the client module's Path and source index by value, and its export names copied into the bundle arena by the bundle thread (copy_export_names_for_reference_proxy, read from graph.ast after the AST is moved in, so the pre-move snapshot and the clones are gone). The arena is freed with the bundle, so a dev server reclaims them on every rebuild; ClientEntryWrapper::path becomes a bundle-lifetime slice for the same reason.
  • With that, the task owns nothing the generated AST can point at, so it is correct to free as soon as the file is generated, the generator stores the arena slices directly (StoreStr's documented "arena-owned" contract; previously the export-name symbols pointed at key boxes inside the task's map and only survived because the task leaked), and the large_enum_variant allowance is unnecessary.
  • Data::ClientEntryWrapper has no producer in the tree, so it is covered but not exercised; client_source_index is only read by production builds, which a dependency-free fixture cannot currently run (custom frameworks panic later in bun build --app, reported separately).
  • Verified:
    • test/bake/deinitialization.test.ts, new case: bake dev server with a separateSSRGraph framework and two "use client" modules (one with three exports, one with none) under detect_leaks=1; asserts the proxy passed the module's path and every export name to registerClientReference, that the export-less module's side effect did not run on the server (its proxy did), no LeakSanitizer report, exit 0. Fails on main with the report above, passes here. skipIf(!isASAN).
    • bun bd test test/bake/dev/bundle.test.ts (includes the separateSSRGraph client-component demotion test, which regenerates proxies across HMR rebuilds): 21 pass.

Background

  • Server components with separateSSRGraph: when the server graph imports a "use client" module, the module itself is bundled for the browser and SSR graphs, and the server graph gets a generated "reference proxy" whose exports are registerClientReference(...) calls, one per export of the client module. The proxy is built with AstBuilder on the bundler's thread pool by a ServerComponentParseTask, which posts a regular parse_task::Result back like a ParseTask would.
  • Unlike ParseTask, which lives in the bundle arena and is bulk-freed with it, ServerComponentParseTask is an individual Box whose only owner is the pool task it embeds, so something has to take it back after it runs.
  • Bundle arena (BundleV2::arena(), graph.heap): a per-bundle mimalloc heap, allocated from only on the bundle thread and destroyed when the bundle finishes (per rebuild in the dev server). Slices in it are stored with an erased 'static lifetime throughout bundle_v2.rs (interned_slice, path_as_static); copy_export_names_for_reference_proxy uses the same convention.
  • AstAlloc: the allocator behind NamedExports. It allocates from the thread's installed AST allocation state, or from global mimalloc when none is installed, and its deallocate is a no-op, so dropping an AstAlloc-backed container frees nothing; only an installed state's owner can reclaim it. Bun.build() installs one for the whole bundle; the bake dev server and bun build --app, the only producers of these tasks, do not have one installed during on_parse_task_complete.
  • StoreStr (Symbol::original_name, EString) stores a raw (ptr, len) and documents that the bytes must be arena-owned or 'static; the bundle arena satisfies that.
Earlier revision

The first revision only reclaimed the box in the callback and copied the export names and entry-wrapper path into the worker arena inside the generators, leaving the Source and NamedExports clones in the task. Review pointed out that the map clone's storage is never reclaimed on the bake paths, so the task's payload was still leaking (just not where LSan looks); the second commit replaces the payload instead.


[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bake/deinitialization.test.ts
error: bindgenv2 emitted unexpected output type: /workspace/bun/build/debug/codegen/GeneratedSocketConfigBinaryType.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigHandlers.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfig.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigTLS.h, /workspace/bun/build/debug/codegen/GeneratedALPNProtocols.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfig.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigFile.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigSingleFile.h, /workspace/bun/build/debug/codegen/GeneratedFakeTimersConfig.h
error: script "bd" exited with code 1
__F:-1:S:0

release without fix: 1 skipped
bun test v1.4.0-canary.1 (da3851e57)

test/bake/deinitialization.test.ts:
bun test v1.4.0-canary.1 (da3851e57)

test.ts:
(pass) baseline: stopped server wrapper collects [0.07ms]
(pass) flags:  [71.55ms]
WebSocket opened
(pass) flags: websocket=1 [18.86ms]
WebSocket closed
WebSocket opened
WebSocket closed
(pass) flags: closeActiveConnections websocket=1 [255.35ms]
Bundled page in 259ms: index.html
(pass) flags: sendAnyRequests [265.62ms]
WebSocket opened
Bundled page in 253ms: index.html
WebSocket closed
(pass) flags: sendAnyRequests websocket=1 [257.32ms]
Bundled page in 254ms: index.html
(pass) flags: closeActiveConnections sendAnyRequests [505.77ms]
WebSocket opened
WebSocket closed
Bundled page in 254ms: index.html
(pass) flags: closeActiveConnections sendAnyRequests websocket=1 [508.49ms]
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
(pass) flags: websocket=8 [3.77ms]
WebSocket closed
WebSocket closed
WebSocket closed
WebSocket closed
WebSocket closed
WebSocket closed
WebSocket closed
WebSocket closed
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
Web
... (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/mechgate.xml" test/bake/deinitialization.test.ts
bun test v1.4.0 (ec7b26792)

test/bake/deinitialization.test.ts:
bun test v1.4.0 (ec7b26792)

test.ts:
(pass) baseline: stopped server wrapper collects [2.51ms]
(pass) flags:  [77.68ms]
WebSocket opened
(pass) flags: websocket=1 [77.39ms]
WebSocket closed
WebSocket opened
WebSocket closed
(pass) flags: closeActiveConnections websocket=1 [305.50ms]
Bundled page in 586ms: index.html
(pass) flags: sendAnyRequests [716.43ms]
WebSocket opened
Bundled page in 340ms: index.html
WebSocket closed
(pass) flags: sendAnyRequests websocket=1 [434.21ms]
Bundled page in 302ms: index.html
(pass) flags: closeActiveConnections sendAnyRequests [602.76ms]
WebSocket opened
WebSocket closed
Bundled page in 358ms: index.html
(pass) flags: closeActiveConnections sendAnyRequests websocket=1 [619.46ms]
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
WebSocket opened
(pass) flags: websocket=8 [56.35ms]
WebSocket closed
WebSocket closed
WebSocket closed
WebSo
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1209ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/34] gen bindgenv2
[2/29] gen cpp.rs (cppbind)
[3/29] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[4/29] gen JS modules (bundle-modules)
Preprocess modules (17098ms)
Bundle modules (246ms)
Postprocesss modules (575ms)
Bundle Functions (1657ms)
Generate Code (40ms)

[19.64s] Bundled "src/js" for production
  2626 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/18] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_paths v0.0.0 (/workspace/bun/src/paths)
�[1m�[92m   Compiling�[0m bun_sys v0.0.0 (/workspace/bun/src/sys)
�[1m�[92m   Compiling�[0m bun_url v0.0.0 (/workspace/bun/src/url)
�[1m�[92m   Compiling�[0m bun_http_types v0.0.0 (/workspace/bun/src/http_types)
�[1m�[92m   Compiling�[0m bun_threading v0.0.0 (/workspace/bun/src
... (truncated)
diff hotspot
src/bundler/ServerComponentParseTask.rs | 66 +++++++++++------------
 src/bundler/bundle_v2.rs                | 62 +++++++++++++--------
 test/bake/deinitialization.test.ts      | 96 ++++++++++++++++++++++++++++++++-
 3 files changed, 167 insertions(+), 57 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                     reads  edits  tests
src/bundler/ServerComponentParseTask.rs      6     21      0
src/bundler/bundle_v2.rs                     8     12      0
test/bake/deinitialization.test.ts           4     10      0

enqueue_server_component_generated_file boxes a ServerComponentParseTask
per generated server-component file and hands it to the worker pool, but
nothing ever took the box back: the pool callback borrowed it, posted the
parse Result, and returned, leaking the task (and whatever its Data owned)
once per "use client" module per bundle.

The pool callback now reclaims the box and drops it once the generated
AST has been built. The two places where the generated AST pointed into
the task's Data (the export-name symbols and string literals of a
reference proxy, and the import path of a client entry wrapper) now copy
those bytes into the worker's AST arena, which is where StoreStr-backed
names are required to live anyway, so nothing refers to the task after
it is freed.
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; current head is ec7b267.

Reproduced with the debug (ASAN) build: a bake dev server using a separateSSRGraph framework that bundles a "use client" module, run with ASAN_OPTIONS=detect_leaks=1, reports Direct leak of 336 byte(s) per client module from Box<ServerComponentParseTask>::new in BundleV2::enqueue_server_component_generated_file. The new case in test/bake/deinitialization.test.ts is that scenario with two client modules (three exports and none): it fails on main with that report and passes on this branch, and it also checks the path and export names the proxies pass to registerClientReference. test/bake/dev/bundle.test.ts (21 tests, including the separateSSRGraph client-component demotion test) passes with the change.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:10 PM PT - Aug 13th, 2026

@robobun, your commit ec7b26792d1659b62a712e40458c0d83b09ae8f9 passed in Build #94755! 🎉


🧪   To try this PR locally:

bunx bun-pr 38004

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

bun-38004 --bun

@coderabbitai

coderabbitai Bot commented Aug 13, 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: ae061f69-c81b-477f-a42e-878fad5dbeac

📥 Commits

Reviewing files that changed from the base of the PR and between f1b3a92 and 22bc24d.

📒 Files selected for processing (3)
  • src/bundler/ServerComponentParseTask.rs
  • src/bundler/bundle_v2.rs
  • test/bake/deinitialization.test.ts

Walkthrough

ServerComponentParseTask now transfers ownership to the worker callback, copies required data into the AST arena, and is reclaimed after generation. Comments document pointer ownership. An ASAN-only test checks output, status, and LeakSanitizer results.

Changes

Server component task lifetime

Layer / File(s) Summary
Owned task generation and arena copies
src/bundler/ServerComponentParseTask.rs, src/bundler/bundle_v2.rs
The worker callback takes ownership of the boxed task. Import paths and export names are copied into the AST arena before task reclamation. Comments document allocation ownership and pointer provenance.
ASAN leak regression coverage
test/bake/deinitialization.test.ts
An ASAN-only test runs a server-component fixture in a child Bun process, verifies generated exports and the HTTP response, and rejects LeakSanitizer output.

Possibly related PRs

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 change: reclaiming the ServerComponentParseTask after file generation.
Description check ✅ Passed The description explains the problem, fix, background, and verification results, covering the template requirements despite using different headings.

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing actionable came out of the automated review. I added two scoping notes to the description: Data::ClientEntryWrapper has no producer in the tree, so only the reference proxy variant is exercised end to end, and the two arena copies are required by the StoreStr contract but are not separately observable by a test (the old pointees are AstAlloc-backed, whose deallocate is a no-op). Waiting on CI.

@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 unsafe cross-thread ownership (reclaiming the boxed task via heap::take on a worker thread) and repoints lifetime-erased StoreStr slices in generated ASTs, a human look would still be worthwhile.

What was reviewed:

  • Thread-pool contract: ThreadPool.rs:1232 never touches the task after the callback returns, matching the OwnedTask::__callback pattern the fix mirrors.
  • Provenance: enqueue projects the intrusive field via addr_of_mut!((*task).task) on the raw box pointer, so from_field_ptr!heap::take recovers whole-allocation provenance.
  • Audited every read of task.data in both generators for slices that outlive the task; the two new arena copies (export keys, entry-wrapper path) cover them, and E::String::init(key) correctly reuses the arena copy instead of double-copying.
  • task.source is moved out before drop(task), and other_source.path.pretty was already arena-copied; framework config strings live on ctx, not the task.
Extended reasoning...

Overview

Fixes a per-generated-file leak of Box<ServerComponentParseTask> in the bundler's server-components path. task_callback_wrap now reclaims the box with heap::take (instead of borrowing it) and drops it after building the AST. Two places where the generated AST previously borrowed slices out of the task's Data — the reference-proxy export names in generate_client_reference_proxy and the import-record path in generate_client_entry_wrapper — now copy into the worker's bump arena, since the task no longer outlives the bundle. Comments at the enqueue site and callback are updated to describe the actual ownership hand-off. A new LSan-gated test in test/bake/deinitialization.test.ts bundles a "use client" module under separateSSRGraph and asserts both the rendered output (catches UAF in the export names) and no LeakSanitizer report.

Security risks

None. This is internal bundler memory management with no user-controlled input reaching the changed code paths beyond what already flowed through them.

Level of scrutiny

High. This is unsafe Rust touching exactly the areas REVIEW.md flags as most-blocked: box reclamation across an FFI-style callback boundary, cross-thread drop (task built on the bundle thread, freed on a worker), and lifetime-erased StoreStr storage where a missed borrow becomes a use-after-free that ASAN may or may not catch depending on allocator reuse. The reasoning in the PR is careful and I verified its load-bearing claims against the source (thread-pool worker loop, OwnedTask::__callback precedent, new_symbol storing the name raw, heap::take = Box::from_raw, the addr_of_mut! provenance path), but this is precisely the class of change where a second pair of eyes on "is there any other slice that escapes the task" is warranted rather than auto-approved.

Other factors

The fix follows the established OwnedTask free-inside-callback pattern from work_pool.rs rather than inventing a new one. The test asserts stdout equals "200 default Alpha Beta\n", which exercises the arena-copied export names through printing and rendering — a UAF there would corrupt the output. The E::String::init(key) change from alloc_slice_copy(key) to bare key is a correct dedup, not a regression: key is already the arena copy at that point. Existing test/bake/dev/bundle.test.ts (which rebuilds proxies across HMR) reportedly still passes. No prior reviews on the PR.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever takes the human look the review asks for, the "does anything else escape the task" audit, so it does not have to be redone from scratch. Reads of the task after it is boxed are all in task_callback and the two generators:

  • task.ctx: copied out (ParentRef is Copy); the Result carries its own copy.
  • task.source: moved out with mem::take into Success.source; AstBuilder only reads source.index from it.
  • ReferenceProxy.other_source: path.pretty is arena-copied in the dev branch and formatted into an arena string in the production branch (both pre-existing), index is copied by value; contents is never read.
  • ReferenceProxy.named_exports: only keys() is read, and each key is now arena-copied before it reaches new_symbol or E::String::init. The named_exports map of the generated AST itself is built by record_export from Symbol::original_name, i.e. from the arena copy, and StringArrayHashMap::put boxes its own key.
  • ClientEntryWrapper.path: now arena-copied before add_import_record.
  • The runtime import and register export names come from the framework config on the bundle (ctx.framework), not from the task.

Nothing else on the task is read, and the thread pool does not touch the intrusive Task after the callback returns, so the drop after task_callback is the last use.

ReferenceProxy carried a clone of the client module's Source and of its
NamedExports map. The map clone is AstAlloc-backed, and on both paths
that produce these tasks (the dev server and `bun build --app`) no AST
allocation state is installed when on_parse_task_complete runs, so the
clone landed in AstAlloc's never-freed global fallback; freeing the task
did not reclaim it. The generator also only ever read the module's path,
its source index, and the export names.

The task now carries exactly those: the path and index by value and the
names copied into the bundle arena by the bundle thread, which is freed
with the rest of the bundle. Nothing in the task needs copying on the
worker any more, so the generator-side arena copies and the
large_enum_variant allowance go away, and the test also checks the path
the proxy hands to registerClientReference.
Comment thread src/bundler/ServerComponentParseTask.rs Outdated
Comment thread src/bundler/ServerComponentParseTask.rs Outdated
Comment thread src/bundler/ServerComponentParseTask.rs Outdated
Comment thread src/bundler/ServerComponentParseTask.rs Outdated
Comment thread src/bundler/ServerComponentParseTask.rs Outdated
Comment thread src/bundler/ServerComponentParseTask.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated

@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 the automated bug hunt found no issues. Because it changes ownership and lifetime handling in unsafe Rust — reclaiming the boxed task inside the thread-pool callback, erasing bundle-arena lifetimes to 'static for the new ReferenceProxy payload, and relying on the pool's run-once contract for the heap::take — a human look is still warranted.

What was reviewed:

  • task_callback_wrap's heap::take pairs with the heap::into_raw at the enqueue site, and the &raw mut (*task).task projection preserves whole-allocation provenance for it.
  • copy_export_names_for_reference_proxy follows the same detach_lifetime_ref arena-erasure convention already used at bundle_v2.rs:1582 and :3341.
  • Nothing in the posted parse_task::Result borrows from the dropped task: source is moved out, ctx is Copy, and the generated AST's strings now point at bundle-arena or worker-arena bytes.
  • The comment-cop flags appear to target the new ownership doc comments and SAFETY blocks rather than actual workarounds.
Extended reasoning...

Overview

This PR fixes a per-"use client"-module leak of ServerComponentParseTask in the bake/server-components bundler path. Two coupled changes: (1) the thread-pool callback now takes back the Box<ServerComponentParseTask> via bun_core::heap::take (paired with the existing heap::into_raw at enqueue) and drops it after generation, and (2) the task's Data payload is restructured so nothing the generated AST points at lives in the task — ReferenceProxy now carries the client module's FsPath, source index, and bundle-arena copies of its export names (via a new copy_export_names_for_reference_proxy helper on BundleV2), replacing the cloned Source and AstAlloc-backed NamedExports map. ClientEntryWrapper::path becomes a bundle-arena &'static [u8]. In on_parse_task_complete, the pre-move named_exports.clone() snapshot is replaced by reading from graph.ast after the AST is moved in. An ASAN-only leak regression test is added.

Security risks

None. This is internal bundler memory management with no user-input parsing, auth, crypto, or network surface.

Level of scrutiny

High. This is native unsafe Rust in a hot bundler path, touching exactly the categories REVIEW.md calls out as most-blocked: cross-thread ownership transfer (box handed to pool, reclaimed in callback), 'static lifetime erasure of arena-backed slices, and pointer-provenance-sensitive projection (from_field_ptr!heap::take). Getting any of these wrong is a use-after-free or double-free rather than a functional bug. The reasoning in the PR description and the author's escape-analysis comment is thorough and the arena-erasure follows the established interned_slice/detach_lifetime_ref convention in the same file, but the invariants are non-local (thread pool run-once contract, bundle-arena lifetime spanning the worker task, StoreStr's arena-owned contract) and merit a maintainer's confirmation.

Other factors

The new test asserts exact rendered output (path + all three export names in order) plus absence of a LeakSanitizer report and exitCode === 0, and is skipIf(!isASAN). The existing test/bake/dev/bundle.test.ts suite (which regenerates proxies across HMR rebuilds) was reported passing. The comment-cop bot left eight "paragraph-long comment" flags; on inspection these land on doc comments and SAFETY blocks documenting ownership — the kind of non-local-lifetime commentary REVIEW.md requires — not on workaround justifications, so they read as linter noise rather than actionable defects, but the author may want to trim or dismiss them. Given the memory-safety surface, deferring to a human reviewer rather than auto-approving.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Update since the reviews above: the second commit (b82c157) changes what the task carries, and 2720ab1 only trims comments.

Self-review of the first revision found that freeing the box was not enough. ReferenceProxy held a clone of the client module's NamedExports, which is AstAlloc-backed; in both the dev server and bun build --app no AST allocation state is installed while on_parse_task_complete runs, so that clone went to AstAlloc's never-freed mimalloc fallback and dropping the task did not reclaim it (LSan cannot see mimalloc memory, so the test was green either way). The generator only ever reads the module's path, its source index, and the export names, so the task now carries exactly those: path and index by value, names copied into the bundle arena on the bundle thread (copy_export_names_for_reference_proxy, read from graph.ast after the AST has been moved in, which also removes the pre-move clone). The arena dies with the bundle, so the dev server reclaims them per rebuild. This supersedes the escape audit above: the task no longer owns anything the generated AST can reference, the generator-side copies are gone, and the test now also checks the path the proxy hands to registerClientReference.

The comment-cop threads are resolved; its rerun on 2720ab1 reports no remaining groups (it cannot resolve threads itself, which is tracked separately).

@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. It's a cross-thread memory-ownership change in the bundler with several unsafe blocks and erased-'static arena lifetimes, so a human look is still worthwhile.

What was reviewed:

  • heap::take in task_callback_wrap reclaims the same allocation enqueue_server_component_generated_file leaked; the pool's one-shot contract matches OwnedTask::__callback.
  • Nothing in the posted Success borrows the dropped task: source is mem::taken, client_path/export_names are bundle-arena copies, and E::String::init(key) / new_symbol(.., key) store StoreStrs over those arena bytes.
  • copy_export_names_for_reference_proxy allocates on the bundle thread from graph.ast after the AST is moved in; FsPath<'static> is Copy and its slices point at arena bytes, not input_files vector storage.
  • ClientEntryWrapper has no producer in src/, so the Box<[u8]>&'static [u8] field change has no callers to break.
Extended reasoning...

Overview

The PR fixes a leak of ServerComponentParseTask in the bake/server-components bundler path. Three files touched: src/bundler/ServerComponentParseTask.rs (reclaim the boxed task in the thread-pool callback and drop it after generation; restructure ReferenceProxy/ClientEntryWrapper to hold bundle-arena slices instead of owned clones), src/bundler/bundle_v2.rs (new copy_export_names_for_reference_proxy helper; enqueue site now builds the proxy payload from graph.ast/graph.input_files after the AST is moved in, dropping the pre-move named_exports.clone()), and a new ASAN-gated leak test in test/bake/deinitialization.test.ts.

Security risks

None identified. This is internal bundler memory management with no user-input parsing, auth, or crypto surface.

Level of scrutiny

High. This is native memory-safety work in the bundler — the most-blocked review category — with new unsafe (heap::take on a field-recovered pointer, detach_lifetime_ref on the bundle arena), cross-thread ownership transfer, and 'static-erased slices whose actual lifetimes are the bundle arena's. The PR description's argument is careful and I could not find a hole in it, but correctness rests on several non-local invariants (thread-pool one-shot execution, bundle arena outliving every AST that stores the export-name slices, StoreStr's arena-owned contract, bundle-arena allocation only on the bundle thread) that a maintainer who owns this code should confirm.

Other factors

  • I traced every read of the task in task_callback and both generators against the new payload shape; nothing surviving the drop(task) points into it. client_path is bun_paths::fs::Path<'static> (Copy, holds &'static [u8] arena slices), so copying it out of &graph.input_files[..] before enqueue_server_component_generated_file takes &mut self is sound and does not alias vector-backed storage.
  • Grepped for ClientEntryWrapper across src/: no constructor exists outside the Default placeholder, so the Box<[u8]>&'static [u8] change is unreachable today.
  • The test follows harness conventions (tempDir, bunEnv spread, concurrent stdout/stderr/exited drain, output asserted before exit code, skipIf(!isASAN)) and asserts the proxy's observable contract (path + export names in export order) in addition to the LSan check.
  • The eight comment-cop bot comments on the timeline predate/target multi-line comments; commit 2720ab17 already trimmed the ones this PR added to one line each. The remaining multi-line blocks are pre-existing SAFETY/CONCURRENCY annotations.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

ec7b267 extends the test rather than changing the fix: the route now also imports a "use client" module with no exports and a module-level side effect. That exercises the zero-length arena copy in copy_export_names_for_reference_proxy and a second freed task, and the rendered undefined shows the proxy, not the module, is what the server graph ran. Description updated to match; nothing else changed since the last review.

@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 reworks ownership and cross-thread lifetimes in unsafe Rust (reclaiming the task box inside the pool callback, and handing bundle-arena slices with erased 'static lifetimes to worker threads), a human look at the arena/provenance invariants would still be worthwhile — the author's escape audit in the thread is a good starting point.

What was reviewed:

  • heap::take in task_callback_wrap matches the OwnedTask::__callback contract in work_pool.rs (pool fires each task exactly once, never touches it after).
  • copy_export_names_for_reference_proxy follows the existing detach_lifetime_ref(self.arena()) pattern used elsewhere in bundle_v2.rs; reads graph.ast after the AST is moved in, so no stale snapshot.
  • Confirmed Data::ClientEntryWrapper has no producer in-tree, so its path: &'static [u8] change is not exercised but also cannot regress anything.
  • Test drains stdout/stderr/exited concurrently, is skipIf(!isASAN), and asserts the proxy's rendered path/export names in addition to the LSan check.
Extended reasoning...

Overview

This PR fixes a per-client-module leak of ServerComponentParseTask in the bake bundler's server-components path. It touches src/bundler/ServerComponentParseTask.rs (task callback now reclaims and drops the boxed task; ReferenceProxy/ClientEntryWrapper payloads reshaped to bundle-arena slices), src/bundler/bundle_v2.rs (new copy_export_names_for_reference_proxy helper; on_parse_task_complete reads named exports from graph.ast after the move instead of cloning them before it), and adds an ASAN-gated regression test to test/bake/deinitialization.test.ts.

Security risks

None. No user-facing input parsing, auth, or network surface is touched — this is internal bundler task lifecycle. The relevant risk class is memory safety (UAF if the box is reclaimed while something still references it, or if arena slices outlive the arena), not security.

Level of scrutiny

High. Per the repo's own review guidance, native memory safety is the most-blocked category. The change adds an unsafe { heap::take(...) } inside a thread-pool callback, an unsafe { detach_lifetime_ref } to hand bundle-arena bytes across threads as &'static, and changes what data crosses the bundle-thread → worker-thread boundary. Each piece follows an established in-tree pattern (OwnedTask::__callback for the box reclaim; multiple existing detach_lifetime_ref(self.arena()) sites in bundle_v2.rs for the arena erasure), and the reasoning in the PR description is thorough, but the invariants ("bundle arena outlives every consumer of these slices", "pool never touches the task after the callback", provenance through from_field_ptr! for Box::from_raw) are exactly the kind a maintainer should sign off on rather than an automated reviewer.

Other factors

  • The bug-hunting pass found nothing. I spot-checked that items_named_exports() is an existing accessor used the same way elsewhere, that alloc_slice_fill_iter is the standard arena API, and that ClientEntryWrapper has no producer (so its Box<[u8]>&'static [u8] change is inert).
  • The test follows harness conventions (tempDir, bunEnv spread, concurrent pipe drain, output asserted before exit code) and asserts functional output — not just "no LeakSanitizer" — so it would catch a UAF that corrupts the export names, and it covers the empty-exports case.
  • The comment-cop threads on the PR are all resolved; the current revision's comments are one-liners.
  • The author explicitly left an escape audit "for whoever takes the human look", which itself signals this was written expecting human sign-off.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Reached the same bug independently (handed over from the #37997 work) and am standing down in favour of this PR. Leaving the branch here in case any of it is useful: main...farm/d654bdb9/free-server-component-parse-task

  • test/bake/dev/server-components.test.ts adds a bun build --app (react preset) variant of the leak test. Production builds create and join their own worker pool before exiting, so LeakSanitizer reports every leaked task there (4 of 4 on main); the test asserts that no leak stack contains enqueue_server_component_generated_file / ServerComponentParseTask rather than exit code 0, because bun build --app has unrelated exit-time leaks (its three transpilers are never dropped, reported separately). It also has a dev-server test of the generated proxy (export names, file/uid arguments, thrower messages, regeneration after a rewrite).
  • Same fix with a different shape: owned_task! plus a ThreadPool::schedule_owned(&self, Box<T>) instance method that WorkPool::schedule_owned now delegates to, so the box round-trip goes through the existing typed helper; dropping the placeholder Default impl then lets the producer-less ClientEntryWrapper variant go as well. 151 insertions, 197 deletions across the three src files.
  • One thing to keep in mind if the test here ever grows: a route importing three or more "use client" modules currently crashes the dev server on its first bundle (finalize_bundle iterates client_components_affected while trace_dependencies appends to it, DevServer.rs:4391; ASAN heap-use-after-free, index-out-of-bounds panic in release). Reported separately as well.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Note for when this lands: test/bake/dev/bundle.test.ts is kept in test/no-validate-leaksan.txt only because of the 336-byte ServerComponentParseTask leak this PR fixes (its case "removing 'use client' from a component with a pending resolution failure" is the only one in the file that still trips the exit-time check on main; the other 20 are clean). #39293 documents that in the list; once this fix is in, the bundle.test.ts entry and its comment lines can be removed, which also gives the fix CI coverage through that 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.

1 participant