Skip to content

resolver: free formatted error text after ResolveMessage.create clones it#30292

Open
robobun wants to merge 1 commit into
mainfrom
farm/b4fb24da/resolve-printed-leak
Open

resolver: free formatted error text after ResolveMessage.create clones it#30292
robobun wants to merge 1 commit into
mainfrom
farm/b4fb24da/resolve-printed-leak

Conversation

@robobun

@robobun robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator

What

resolveMaybeNeedsTrailingSlash allocPrints the "Cannot find package '...' from '...'" text into the VM's MimallocArena, then hands it to ResolveMessage.create, which immediately msg.clone()s it into a second arena allocation that the ResolveMessage owns and frees in finalize(). The original allocation was never freed, so every failed require()/import of a bare specifier leaked one string into the same mimalloc heap that ResolveMessage structs are destroyed back into on GC.

Same pattern fixed in:

  • the NameTooLong fast path (leaked into bun.default_allocator)
  • the empty-log branch of processFetchLog (leaked into globalThis.allocator())

Also switched the create() call to use the already-bound jsc_vm.allocator instead of re-fetching VirtualMachine.get().allocator — they are the same heap, but the local is already in hand.

Why

Fuzzilli hit a very-flaky use-after-poison (12-byte read inside a poisoned ~280-byte arena block, same __interceptor_memcpy → … → functionImportMeta__resolveSync shape as the crash addressed by #29840) with:

try { this.require("804"); } catch (e) {}
Bun.gc(true);

run thousands of times in the REPRL process. I was unable to reproduce the poison hit deterministically (20,000+ REPRL iterations clean), but the leaked printed string is the one remaining allocation in this path that accumulates unbounded in the exact arena the crash points into, so it is the most plausible remaining contributor — and a real leak regardless.

Test

Added a bare-specifier stress to resolve-error.test.ts alongside the existing relative-specifier one from #29840.

…ones it

resolveMaybeNeedsTrailingSlash allocPrints the "Cannot find package
'...' from '...'" text into the VM arena, then passes it to
ResolveMessage.create which immediately msg.clone()s it into a second
arena allocation owned by the ResolveMessage. The original was never
freed, so every failed require()/import of a bare specifier leaked one
string into the same mimalloc heap that ResolveMessage structs are
destroyed back into on GC.

Same pattern for the NameTooLong fast path (bun.default_allocator) and
the empty-log branch of processFetchLog.

In a long-running Fuzzilli REPRL process this accumulated across
thousands of iterations and surfaced as an intermittent
use-after-poison (12-byte read in a poisoned ~280-byte arena block)
with the same signature as the issue addressed in 9a2997b.

Also switch the create() call to use the already-bound jsc_vm.allocator
instead of re-fetching VirtualMachine.get().allocator.
@github-actions github-actions Bot added the claude label May 5, 2026
@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

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: 94132903-5eea-447d-98fb-b4f84f0f7c42

📥 Commits

Reviewing files that changed from the base of the PR and between b009453 and 5607d4f.

📒 Files selected for processing (2)
  • src/jsc/VirtualMachine.zig
  • test/js/bun/resolve/resolve-error.test.ts

Walkthrough

This pull request fixes memory leaks in error message formatting within the resolve functionality. The VirtualMachine.zig file now properly frees formatted message buffers in error paths and refactors text allocation in processFetchLog with explicit cleanup. A regression test verifies no message leaks occur on the bare-package error path.

Changes

Memory Leak Fixes in Resolve Error Paths

Layer / File(s) Summary
Buffer Cleanup in Error Paths
src/jsc/VirtualMachine.zig (lines 1892–1895, 1957–2002)
resolveMaybeNeedsTrailingSlash now defers freeing the temporary printed buffer from ResolveMessage.fmt() in both the error.NameTooLong handler and _resolve catch block. Switches ResolveMessage.create to use jsc_vm.allocator instead of VirtualMachine.get().allocator.
Text Allocation Refactor
src/jsc/VirtualMachine.zig (lines 2038–2043)
processFetchLog now allocates text via std.fmt.allocPrint, immediately defers its cleanup, and constructs logger.Msg directly instead of via a brk block for cleaner resource management.
Regression Test
test/js/bun/resolve/resolve-error.test.ts (lines 113–143)
New test verifies no formatted message leaks on the bare-package error path by repeatedly calling require("804"), accessing error properties, stringifying, and triggering GC to detect persistent allocations.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and specifically describes the main change: freeing formatted error text after ResolveMessage.create clones it, which is the core fix across the modified resolver code.
Description check ✅ Passed The PR description fully addresses both required template sections: 'What' explains the memory leak fix across three paths, and 'How' documents the test added to verify the fix.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Issue #28177 - Use-after-poison in strlen during the module-loader path with ASAN shadow byte f7 (poisoned by user) — matches exactly the class of bug this PR fixes: leaked allocPrint/fmt strings get poisoned after the allocator reclaims them, then crash in strlen when the dangling pointer is later accessed.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #28177

🤖 Generated with Claude Code

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

LGTM — straightforward leak fix; verified that ResolveMessage/BuildMessage.create clone the message text and BabyString metadata is offset-based, so the deferred frees are safe.

Extended reasoning...

Overview

This PR fixes three small memory leaks in src/jsc/VirtualMachine.zig where error-message strings were allocPrinted, passed to ResolveMessage.create / BuildMessage.create (which immediately msg.clone() them), and then never freed. The fix adds defer ... free() for each, plus a matching stress test in resolve-error.test.ts. It also replaces a redundant VirtualMachine.get().allocator with the already-bound jsc_vm.allocator.

Correctness verification

I checked ResolveMessage.create (src/jsc/ResolveMessage.zig:174) and BuildMessage.create (src/jsc/BuildMessage.zig:54) — both call msg.clone(allocator). Msg.cloneData.clone does allocator.dupe(u8, this.text) (src/logger/logger.zig:232), so the original buffer is no longer referenced after create() returns. The .metadata field is shallow-copied, but BabyString is an offset+len pair (src/logger/logger.zig:371), not a pointer, so it remains valid against the duped text. The printed.len > 0 guard correctly avoids freeing the "" literal when the log already contained a resolve message, and each free is paired with the same allocator used for the corresponding allocPrint/fmt.

Security risks

None. This is purely allocator hygiene on an error path; no user-controlled data flow, parsing, or boundary changes.

Level of scrutiny

Low-to-medium. The change is ~20 lines, mechanical, and mirrors the pattern from #29840 in the same file. The added test follows the existing adjacent test exactly.

Other factors

No prior reviews or comments on the PR. No bugs flagged by the bug-hunting system. The processFetchLog refactor is a pure restructuring (labeled-block → if-expression) with identical semantics plus the new defer free.

@robobun

robobun commented May 5, 2026

Copy link
Copy Markdown
Collaborator Author

CI failure is the pre-existing flaky test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts on Windows — also failing on unrelated builds 51844, 51842, and main build 50900. The warnings (spawn-ipc-gc, jsc-stress mprotect, hot.test, fetch-leak) all passed on retry.

resolve-error.test.ts (including the new test) passed on all platforms. The Lint JavaScript failure is pre-existing in src/js/internal/sql/errors.ts, untouched by this PR.

@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:39 PM PT - May 5th, 2026

@robobun, your commit 5607d4f has 2 failures in Build #51847 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30292

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

bun-30292 --bun

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