bake: release the strings production.rs returns to the module loader hooks - #38960
bake: release the strings production.rs returns to the module loader hooks#38960robobun wants to merge 3 commits into
Conversation
…hooks BakeProdResolve, BakeProdLoad and BakeToWindowsPath return a BunString that owns a reference to its WTF string. bakeModuleLoaderResolve, bakeModuleLoaderImportModule and bakeModuleLoaderFetch read the result with toWTFString(), which takes a second reference and never drops the one they were handed, so every import edge resolved and every chunk loaded while prerendering a production build leaked its string. Consume the results with transferToWTFString(), as the referrer-less branch of bakeModuleLoaderResolve already did. The tests build a two page app under LeakSanitizer (Malloc=1 so WTF strings are visible to it) and fail on any leak record allocated inside BakeProdResolve or BakeProdLoad.
|
Warning Review limit reached
Next review available in: 20 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 12:13 PM PT - Aug 15th, 2026
🔄 @robobun, the build for your commit |
|
Status: fix pushed (2498432); the diff is green on every lane that exercises it, ready for a maintainer. Reproduced on an unfixed debug (ASAN) build by running Revision history: the first revision fixed the four BakeGlobalObject.cpp sites; the second extended the fix to the same mistake in the dev server's HMR patch entry points and in production.rs's own strings, and widened the tests to the whole class; the third encodes the HMR ownership in the wrapper types instead of comments. CI for 2498432 (#98233): 176 jobs green, including all 20 x64-asan shards (production.test.ts 11/11 in about 12s). The one red job is test/bake/deinitialization.test.ts segfaulting on Windows 2019 x64 during dev server teardown; that test runs an HTML-only dev server, which never reaches the code this PR touches (the HMR patch path is behind |
There was a problem hiding this comment.
LGTM — mechanical leak fix that brings four call sites in line with the existing transferToWTFString() pattern already used in the same file.
What was reviewed:
- Verified
transferToWTFString()in BunString.cpp handles all three tags these functions return:WTFStringImpl(adds ref, then derefs the BunString's own — net transfer),StaticZigString(viatoStringStatic, same as the oldZeroCopypath, so the builtin-alias case at production.rs:1351 is unaffected), andDead(guarded before the call inbakeModuleLoaderFetch). - Confirmed
BakeProdResolve/BakeProdLoad/BakeToWindowsPatheach return a +1-ownedBunStringon their non-static/non-dead paths (create_format,to_bun_string_ref,clone_utf16), and that the throw path returnsdead()soRETURN_IF_EXCEPTIONbefore the transfer does not leak. - The referrer-less branch of
bakeModuleLoaderResolvealready usedtransferToWTFString(); this makes the other four sites consistent.
Extended reasoning...
Overview
Four one-token substitutions in src/runtime/bake/BakeGlobalObject.cpp (toWTFString() → transferToWTFString()) at the sites consuming BunString results from BakeProdResolve, BakeProdLoad, and BakeToWindowsPath, plus a comment on the extern declarations documenting the ownership contract. Two ASAN-only LSan regression tests in test/bake/dev/production.test.ts that build a two-page app under Malloc=1 + detect_leaks=1 and assert no leak records rooted in BakeProd(Resolve|Load).
Security risks
None. This is a per-import-edge string leak in the bun build --app prerender VM (a build-time tool), not a runtime request path. No user input handling, auth, or crypto is touched.
Level of scrutiny
Medium — refcount management across the Rust/C++ FFI boundary is exactly the class REVIEW.md flags. But the change is narrow and I verified the semantics directly:
transferToWTFString()(BunString.cpp:820-846) constructsWTF::String(impl.wtf)(which refs) thenimpl.wtf->deref(), netting a transfer of the caller's +1 into the returnedWTF::String. ForStaticZigStringit takes the sametoStringStaticpath the previoustoWTFString(ZeroCopy)call took, soBakeProdResolve's static builtin-alias return is unchanged.BakeProdResolve(production.rs:1339-1379) returnsstatic_(),dead()(on throw, before the transfer runs), orcreate_format()— the last is the only owned case.BakeProdLoadreturnsto_bun_string_ref()(ownedExternalStringImpl) ordead()(guarded bysource.tag != Deadbefore the transfer).BakeToWindowsPathreturnsclone_utf16()(owned).- The fifth sibling site in the same file (the referrer-less
bakeModuleLoaderResolvebranch) already usedtransferToWTFString(), so this is convergence to an established local pattern, not a novel one.
Other factors
The tests are well-constructed per the repo's leak-test conventions: they filter LSan output to only records allocated inside BakeProd* (ignoring unrelated process-lifetime bundler state and the LSan-forced exit code), assert the build actually exercised the code paths (rendered HTML / thrown error present) before checking for zero leaks, run test.concurrent, skip on non-ASAN and Windows, and justify the 60s timeout (llvm-symbolizer against the debug binary). The PR description's analysis matched the code at every point I checked. The noted overlap with #38714/#38949 is a rebase concern, not a correctness one.
The same ownership mistake exists beyond the production module loader hooks: the dev server's HMR patch entry points read the patch source DevServer.rs copies for them with toWTFString(), so a copy of the server bundle leaked on every server-side hot update, and production.rs itself converted the config path, client entry URLs, CSS chunk URLs and route patterns with the borrowing to_js() and kept the module keys in a plain Vec, so none of those were released either. Consume them with transferToWTFString() / transfer_to_js() and hold the long-lived ones in OwnedString. The tests now fail on any leaked string created through bun_core::String during a production build rather than only on the two module loader functions, and check that the leak scan actually ran.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes refcount ownership at several Rust↔C++ FFI sites (BunString → WTFString transfer, plus OwnedString/transfer_to_js on the Rust side and the two DevServer HMR entry points), a human look is still worthwhile.
What was reviewed:
transferToWTFString()handles all three tagsBakeProdResolvereturns (WTFStringImpl derefs after wrapping, StaticZigString goes throughtoStringStatic, and the thrown/dead case is short-circuited byRETURN_IF_EXCEPTION).OwnedString::dropcallsString::deref(), which is a no-op forTag::Dead, so the placeholdermodule_keysslots andVecreassignment don't over-release.- Both DevServer HMR callers pass
clone_utf8/clone_latin1(a fresh +1), so consuming withtransferToWTFString()on the C++ side is balanced. - The comment-cop flags look like false positives — the added comments are two/three-line FFI ownership contracts (which REVIEW.md asks for), not workaround justifications.
Extended reasoning...
Overview
The PR fixes WTF::StringImpl refcount leaks in bun build --app's prerender module loader. Four call sites in BakeGlobalObject.cpp and two in BakeSourceProvider.cpp are switched from toWTFString() (which adds a ref, leaving the caller's +1 orphaned) to transferToWTFString() (which adopts the caller's +1). On the Rust side, production.rs wraps several long-lived BunStrings in OwnedString so their +1 is released on drop, and switches short-lived ones from .to_js() to transfer_to_js(). DevServer.rs gains doc comments on the HMR wrappers stating the C++ side now consumes the passed string. Two ASAN-only LSan tests are added to test/bake/dev/production.test.ts.
Security risks
None. This is refcount bookkeeping on strings the process itself created; no untrusted input handling or auth surface changes.
Level of scrutiny
High. Refcount ownership across the Rust/C++ boundary is REVIEW.md's "most-blocked category," and the correctness of each site depends on what the producing function actually returns. I verified: transferToWTFString() in BunString.cpp:820-846 refs-then-derefs for WTFStringImpl and uses toStringStatic for StaticZigString, matching the two non-thrown paths of BakeProdResolve; OwnedString in bun_core/string/mod.rs:1172-1222 calls self.0.deref() on drop, which is a no-op for non-WTFStringImpl tags (so the dead() placeholders and index reassignment are safe); the DevServer HMR callers at DevServer.rs:4257,4275 pass clone_utf8/clone_latin1, so the new transferToWTFString() on the C++ side is balanced. The remaining .to_js() reads on module_keys[i] and config_entry_point_string go through Deref to &String and don't consume, so the OwnedString drop is the single release.
Other factors
- Three
comment-copbot comments flag the added code comments. Those comments are 2–3 line FFI ownership contracts, which REVIEW.md explicitly asks for ("comment it when non-local, especially across FFI"), so they read as false positives — but the author may want to trim them to satisfy the linter. - The new tests parse LSan output with regexes and depend on
Malloc=1+log_threads=1behavior; that's a somewhat brittle harness a human should sanity-check for CI stability, though thedescribe.skipIf(!isASAN || isWindows)gate keeps it off non-ASAN lanes. - The PR description notes overlap with #38714 and #38949; whichever lands second needs a one-line rebase.
- CI is reported green on 177 jobs including the x64-asan lane where the new tests run.
The two wrappers now take the string type that says the callee releases it, instead of a doc comment saying so, and the remaining ownership notes are one line each. The leak tests get a larger budget: symbolizing the report takes tens of seconds against the debug binary on a loaded machine.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes refcount ownership across the Rust/C++ FFI boundary (the most-blocked category per REVIEW.md), a human look would still be worthwhile.
What was reviewed:
- Verified
transferToWTFString()(BunString.cpp:820) handles every tagBakeProdResolve/BakeProdLoad/BakeToWindowsPathcan return —StaticZigStringfor the builtin-alias path,WTFStringImplforcreate_format/clone_utf16/to_bun_string_ref's ExternalStringImpl, andDead/Emptyfall through to an empty string — so no borrowed value is over-released. - Verified
OwnedString::into_inner()disarms Drop viaManuallyDrop, so the DevServer.rs →BakeLoadServerHmrPatch*handoff is single-owner with no double-free;from_js_host_callalways invokes its closure. - Verified
Vec<OwnedString>formodule_keys: the initialdead()slots deref to a no-op, overwriting a slot correctly drops the previous value, and the remaining.to_js()reads go throughDeref<Target=BunString>without consuming. - The comment-cop bot flags look like false positives — the added comments are one-line FFI ownership notes, which REVIEW.md asks for.
Extended reasoning...
Overview
The PR fixes WTF string leaks in bun build --app's prerender module loader by switching four toWTFString() sites in BakeGlobalObject.cpp and two in BakeSourceProvider.cpp to transferToWTFString(), switching three to_js() sites in production.rs to transfer_to_js(), and wrapping module_keys/config_entry_point_string and the DevServer HMR patch sources in OwnedString so their +1 is released on Drop. Two ASAN-gated LSan tests are added that fail on the unfixed build and pass with the fix.
Security risks
None. This is refcount bookkeeping on strings created and consumed inside a single build process; no untrusted input, auth, or network surface is touched.
Level of scrutiny
High. REVIEW.md calls out native memory safety as the most-blocked category, and every change here is about who releases which +1 across the Rust/C++ boundary. I traced each producer (create_format, clone_utf8/16, to_bun_string_ref, static_, dead) through transferToWTFString()'s tag switch and confirmed none over-releases; I checked OwnedString (new/into_inner/Drop) and BunString::deref() (no-op unless WTFStringImpl) so the Vec<OwnedString> initialization with dead() and later slot assignment are safe; and I confirmed from_js_host_call unconditionally calls its closure so into_inner() always runs and the C++ side always reaches transferToWTFString() before anything can throw. Everything checks out, but a mistake here is a UAF, so it merits maintainer sign-off.
Other factors
CI is green on 177 lanes including x64-asan where the new LSan tests run. The comment-cop bot left three "paragraph-long comment" flags, but the added comments are one-liners documenting FFI ownership — exactly what REVIEW.md asks for ("comment it when non-local, especially across FFI") — so they read as false positives; a maintainer should confirm and dismiss. The PR description also notes overlap with #38714 and #38949 that will need a one-line rebase whichever lands second.
Problem
Malloc=1, see Background) reports them on a two pagebun build --appas, for example:bun_core::String(BunString) holding aWTF::StringImplis a plain struct whose reference has to be released by exactly one consumer. These consumers used the borrowing conversions instead, which take a reference of their own and leave the one they were handed:BakeProdResolve(lines 48 and 74),BakeProdLoad(140) and, on Windows,BakeToWindowsPath(162) read withtoWTFString(). One leak per import edge resolved and per chunk loaded while prerendering. The referrer-less branch at line 86 already usedtransferToWTFString().BakeLoadServerHmrPatchandBakeLoadServerHmrPatchWithSourceMap(lines 69 and 93) read the patch source withtoWTFString(). DevServer.rs (finalize_bundle, lines 4257 and 4275) hands them a freshclone_utf8/clone_latin1copy of the whole server bundle and never touches it again, so the dev server leaked one copy of the server bundle per server-side hot update.to_js()(compare thetransfer_to_js()directly below at 1124), and the module keys (746) were kept in a plainVec<BunString>, which frees nothing when dropped.Fix
transferToWTFString()(six sites in the two files above). production.rs converts its temporaries withtransfer_to_js()and holds the config path andPerThread.module_keysinOwnedString, the RAII wrapper the rest of bake (DevServer.rs, bake_body.rs, FrameworkRouter.rs) already uses.transferToWTFString()/transferToJS()(src/jsc/bindings/BunString.cpp) build theWTF::StringorJSStringand then drop theBunString's own reference, so the string ends up owned only by the module key,SourceProvider, or JS string it was converted into;OwnedStringdrops the reference when the owner goes away. The non-owned values these paths can carry are unaffected:transferToWTFString()on the static builtin aliasBakeProdResolvereturns takes the sametoStringStaticpath line 74'stoWTFString(ZeroCopy)already took,transferToJS()andtoJS()treatEmpty/Deadidentically, and the thrown cases return before any conversion.OwnedStringand hand its reference to C++ withinto_inner(), so the type says the callee releases the string;finalize_bundle, their only caller, wraps the copies it makes.BakeLoadInitialServerCodekeeps a plainBunStringbecause it receives a static string.log_threads=1makes it announce itself), and fails on any leak record that has bake's string machinery (BunString__*/bun_core::string) on its stack, reduced to the frames that created the string:BUN_DESTRUCT_VM_ON_EXIT=1. Only that exit path tears the VM down (production.rsbuild_command), which is what makes the chunk sources, config path and client entry URL visible to LSan. Fails before with 19 records, passes after.bun bd test --timeout 90000 test/bake/dev/production.test.ts: 11/11 (the pre-existing tests need more than the 5s local default on a debug build; CI passes its own timeout). On CI's x64-asan lane the file passes 11/11 in about 12s (builds 97734 and 98233).BakeLoadServerHmrPatchWithSourceMapacross repeated reloads and the provider's destruction at exit, so the string now being owned solely by the provider is exercised; the plain variant differs only in the provider class, which the production tests cover.BakeProdLoadhunk and the two BakeSourceProvider.cpp hunks incidentally (identical lines) and also edits theclone_latin1call infinalize_bundlethat this PR wraps inOwnedString; bake: check for exceptions in the production build's module helpers #38949 rewrites theimport()line of BakeGlobalObject.cpp while keepingtoWTFString()there. Whichever lands second needs a one-line rebase in each case, and the end state should keep the transferring calls everywhere. This PR is the one that is about the ownership bug and carries the test for it.Background
BunString(bun_core::Stringin Rust) is the string type shared across the Rust/C++ boundary. With theWTFStringImpltag it points at a refcountedWTF::StringImpl, and the value itself isCopywith no destructor: a reference it holds is released only by explicit code. A Rust function returning one by value, or passing one by value to C++, hands its +1 reference to the receiver.toWTFString()/to_js()copy a string out by adding a reference (for strings the caller does not own);transferToWTFString()/transfer_to_js()do the same and then release theBunString's reference (for strings it does own).OwnedStringis the Rust RAII wrapper that releases on drop.Bake::GlobalObjectis the JS global onlybun build --appuses to prerender routes. Its module loader hooks mapbake:/...specifiers onto the server chunks the bundler just produced:bakeModuleLoaderResolveandbakeModuleLoaderImportModuleturn a specifier plus referrer into a key viaBakeProdResolve;bakeModuleLoaderFetchgets a chunk's source fromBakeProdLoad. The dev server instead evaluates each incremental server bundle through the HMR patch entry points in BakeSourceProvider.cpp.Malloc=1environment variable makes bmalloc route through the system allocator (bmalloc's Environment.cpp checks it unconditionally).Identifier::fromStringinterns its string in the per-thread atom table, so the first resolution of each key stays reachable through that table and only repeat resolutions (every shared chunk) show up as records; the tests' two pages import the same chunks for that reason.Leak records from the unfixed build for the two test scenarios
Successful build (4 records):
Failed build with
BUN_DESTRUCT_VM_ON_EXIT=1(19 records):With this branch both scenarios report zero records with string machinery on the stack; the remaining LSan output is unrelated process-lifetime bundler state, which is why test/bake/dev/production.test.ts stays on test/no-validate-leaksan.txt and the tests filter instead of asserting an empty report.