-
Notifications
You must be signed in to change notification settings - Fork 4.7k
ResolveMessage/BuildMessage: match allocator in finalize and destroy struct #29840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,6 +76,39 @@ describe("ResolveMessage", () => { | |
| expect(err.referrer).toStartWith("/tmp/caf"); | ||
| expect(err.referrer).toEndWith("/file.js"); | ||
| }); | ||
|
|
||
| it("finalize frees with the same allocator it was created with", () => { | ||
| // ResolveMessage.create() clones the message with the VM's arena | ||
| // allocator but finalize() was freeing it with bun.default_allocator | ||
| // and never destroying the struct itself. Under ASAN with mimalloc's | ||
| // per-heap tracking this surfaced as a flaky use-after-poison in the | ||
| // resolver after many failed require()s + GCs in a long-running | ||
| // process (Fuzzilli REPRL). Use relative specifiers so auto-install | ||
| // does not kick in. | ||
| for (let i = 0; i < 50; i++) { | ||
| let errs: any[] = []; | ||
| for (let j = 0; j < 10; j++) { | ||
| try { | ||
| Bun.resolveSync("./does-not-exist-" + j, import.meta.dir); | ||
| } catch (e) { | ||
| errs.push(e); | ||
| } | ||
| } | ||
| for (const e of errs) { | ||
| void e.message; | ||
| void e.code; | ||
| void e.specifier; | ||
| void e.referrer; | ||
| void e.level; | ||
| void e.importKind; | ||
| void e.position; | ||
| void String(e); | ||
| } | ||
| errs = []; | ||
| Bun.gc(true); | ||
| } | ||
| expect().pass(); | ||
| }); | ||
|
Comment on lines
+80
to
+111
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Add a matching This stress case only exercises the 🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| // These tests reproduce panics where the module resolver wrote past fixed-size | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 🟣 Pre-existing (not introduced by this PR): two leaks remain in the exact
BuildMessageclone/finalize lifecycle this PR is making leak-free. (1)Location.deinit(src/logger.zig:149) is a no-op even thoughLocation.clonedupesfileandline_text, so everyBuildMessage/ResolveMessagewhosemsg.data.locationis non-null still leaks those two strings on finalize. (2)BuildMessage.getNotescallsnote.clone(bun.default_allocator)and then passes the result toBuildMessage.create(), which clones again — the first clone's.text/location strings are never freed. Both are exercised by the newbuild-error.test.tsstress loop (e.position,e.notes); not blocking, but might be worth folding in while you're touching this.Extended reasoning...
What the bugs are
Two distinct leaks remain on the
logger.Msgclone/deinit chain thatBuildMessage.finalize()now drives. Neither is introduced by this PR — both were present before — but both sit in the exact lifecycle this PR is making leak-free, and both are exercised by the newbuild-error.test.tsstress test.(1)
Location.deinitis a no-op even thoughLocation.cloneallocatessrc/logger.zig:113-123:src/logger.zig:148-149:The deinit chain on finalize is
BuildMessage.finalize→Msg.deinit(logger.zig:502) →Data.deinit(logger.zig:209) →Location.deinit(logger.zig:149).Data.deinitcallsloc.deinit(allocator)and then frees onlyd.text;Location.deinititself does nothing. So the dupedfileandline_textstrings produced byLocation.cloneare never freed.(2)
BuildMessage.getNotesdouble-clones each notesrc/bun.js/BuildMessage.zig:16-29:BuildMessage.create()at line 54 doestry msg.clone(allocator), which callsData.cloneagain on the already-cloned data. Clone #2 is stored in the newBuildMessageand freed by itsfinalize(); clone #1 (cloned) is a stack-localDatawhose heap-backed.text(and.locationstrings) are never deinit'd. Thenote.clone()call is completely redundant.Step-by-step proof (leak 1)
function bad( {→ the bundler emits at least one parse-errorBuildMessagewhosemsg.data.locationis non-null (parse errors carryfile+line_text).BuildMessage.create()callsmsg.clone(allocator)(BuildMessage.zig:54) →Msg.clone(logger.zig:437) →Data.clone(logger.zig:230) →Location.clone(logger.zig:113), whichallocator.dupesfileandline_text.Bun.gc(true)sweeps the wrapper →finalize()callsthis.msg.deinit(this.allocator).Msg.deinit→Data.deinitcallsloc.deinit(allocator)(no-op) thenallocator.free(d.text). The dupedfileandline_textare never passed toallocator.free.BuildMessageper iteration × 20 iterations.Step-by-step proof (leak 2)
void e.notes;on eachBuildMessage.this.msg.notes,getNotescallsnote.clone(bun.default_allocator)→Data.clonedupes.text(andLocation.clonedupesfile/line_textif present). Call this allocation set A.Msg{ .data = cloned }is passed toBuildMessage.create(), which callsmsg.clone(allocator)→Data.cloneagain, producing allocation set B stored in the childBuildMessage.finalize()frees B. A is never referenced again and leaks per note per.notesaccess.(Caveat: whether
function bad( {produces a message with non-emptynotesdepends on the parser; ifnotes.len == 0the loop body doesn't run and only leak (1) is hit by this specific test. The double-clone is real regardless.)Why existing code doesn't prevent it
This PR correctly switches
finalize()tothis.msg.deinit(this.allocator)and addsthis.allocator.destroy(this), but it relies onMsg.deinitto free everythingMsg.cloneallocated. The chain bottoms out inLocation.deinit, which is intentionally empty (// don't really know what's safe to deinit here!). AndgetNotesis unchanged by this PR, so the redundant intermediate clone is still there.Impact
Small, bounded per-message leaks (a couple of short strings each). They will not cause crashes or correctness issues, only gradual RSS growth in long-running processes that repeatedly surface build/resolve errors with locations or notes — exactly the Fuzzilli-REPRL-style scenario this PR is targeting. Severity: pre-existing / non-blocking.
Suggested fixes
For (1), make
Location.deinitsymmetric withLocation.clone:This is safe on the
BuildMessage/ResolveMessagepath because theLocationwas produced byLocation.clonewith the same allocator now passed todeinit. (Other callers ofData.deinitthat did not go throughclonewould need auditing — which is presumably why the original author left it a no-op — so an alternative is to freelocation.file/line_textdirectly inMsg.deinitonly when called from the cloned-msg path, or to add aLocation.deinitClonedvariant.)For (2), drop the redundant clone in
getNotes—create()already deep-copies: