Skip to content

MarkedArrayBuffer.destroy: don't free the struct itself#30166

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/8a31a42a/fix-readdir-buffer-error-cleanup
May 3, 2026
Merged

MarkedArrayBuffer.destroy: don't free the struct itself#30166
Jarred-Sumner merged 2 commits into
mainfrom
farm/8a31a42a/fix-readdir-buffer-error-cleanup

Conversation

@robobun

@robobun robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

MarkedArrayBuffer.destroy() did two things:

allocator.free(content.buffer.slice()); // free the bytes
allocator.destroy(this);                 // free *this

Every constructor that is actually used (fromString, fromBytes, fromJS, fromTypedArray, fromArrayBuffer) returns MarkedArrayBuffer by value, so this is never an individually heap-allocated struct — it's a stack local, an embedded field, or an ArrayList slot. The allocator.destroy(this) call passes that interior pointer to mimalloc.

In the readdir Buffer error-cleanup path (readdirWithEntries / readdirInner), entries are appended by value via Buffer.fromString():

  • allocator.destroy(&entries.items[0]) frees entries.items.ptr
  • the next loop iteration reads this.* from poisoned memory
  • entries.deinit() frees the same pointer again

Repro

const fs = require('fs');
// dir contains regular files + a self-referential symlink 'loop -> loop'
fs.readdirSync(dir, { encoding: 'buffer', recursive: true });

The recursive walk collects Buffer entries for the root, then fails with ELOOP opening the symlink (not in the swallowed NOENT/NOTDIR/PERM set), and enters the cleanup loop. Under ASAN:

==3593==ERROR: AddressSanitizer: use-after-poison on address 0x737ec6e50040
READ of size 64 at 0x737ec6e50040 thread T0
    #1 MarkedArrayBuffer.destroy          array_buffer.zig:591
    #2 NodeFS.readdirInner                node_fs.zig:5013
    #3 NodeFS.readdir                     node_fs.zig:4518

Fix

  • Drop allocator.destroy(this) from MarkedArrayBuffer.destroy(). The struct is passed/stored by value; callers own its storage.
  • Remove the unused MarkedArrayBuffer.init() (the only function that heap-allocated the struct, zero callers) so there's no pairing that would leak.
  • The readdir call sites keep calling .destroy(), which still checks this.allocator before freeing bytes — JS-owned buffers remain untouched.

Also fixed the adjacent Dirent arm of the recursive-sync error cleanup: result.name.deref()result.deref() so Dirent.path is released too (matching the non-recursive and async cleanup sites).

Verification

New test in test/js/node/fs/fs.test.ts creates a temp dir with files + a self-referential symlink, spawns a subprocess that calls readdirSync({encoding:'buffer', recursive:true}), and asserts it throws ELOOP and exits 0.

# without fix
(fail) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ...
  { exitCode: 134, stdout: "" }  # SIGABRT from ASAN

# with fix
(pass) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ... [1.5s]
  { exitCode: 0, stdout: "ELOOP" }

zig:check-all passes on all targets.

…on error

The MarkedArrayBuffer structs appended to the entries ArrayList by
Buffer.fromString() are stored by value, so calling item.destroy() passes an
interior ArrayList pointer to allocator.destroy(). For index 0 this frees
entries.items.ptr, which entries.deinit() then frees again. Match the existing
ResultListEntry.Value.deinit() pattern and free only the owned byte slice.
@github-actions github-actions Bot added the claude label May 3, 2026
@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:28 AM PT - May 3rd, 2026

@robobun, your commit 3b06a51 has 4 failures in Build #50468 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30166

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

bun-30166 --bun

@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Fixed memory cleanup in recursive readdir operations when buffer encoding is used and an error occurs. The fix replaces full MarkedArrayBuffer destruction with targeted byte-slice deallocation to prevent double-free issues. A regression test ensures the error path remains safe.

Changes

Recursive Readdir Buffer Cleanup Fix

Layer / File(s) Summary
Core Implementation
src/bun.js/node/node_fs.zig
In readdirWithEntries (lines 4565–4569) and readdirInner (lines 5017–5021), error-path cleanup for ExpectedType == Buffer now calls bun.default_allocator.free(item.buffer.byteSlice()) instead of item.destroy() to safely release owned buffer memory without double-free.
Regression Test
test/js/node/fs/fs.test.ts
New test (lines 1181–1232, skipped on Windows) creates a symlink loop and spawns a subprocess that calls fs.readdirSync(..., { encoding: "buffer", recursive: true }) twice, verifying the error is reported as ELOOP and cleanup succeeds.
🚥 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 describes the core structural fix (not freeing the struct itself in destroy), but doesn't directly convey the main issue being solved (readdir Buffer error-cleanup safety).
Description check ✅ Passed The PR description comprehensively covers the problem, repro steps, fix details, and verification approach, exceeding the basic template requirements.

✏️ 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.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/node/fs/fs.test.ts`:
- Around line 1227-1229: The test currently ignores stderr and checks exitCode
immediately; change the Promise.all destructure to capture stderr (use
proc.stdout.text(), proc.stderr.text(), proc.exited) into e.g. [stdout, stderr,
exitCode], then assert stdout.trim() === "ELOOP", assert stderr.trim() === ""
(or empty string) next, and finally assert exitCode === 0 so stderr is
explicitly checked and the exit-code assertion remains last; update references
to stdout, stderr, and exitCode accordingly in the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5ed1211a-8d3f-4071-9948-3deb8c998855

📥 Commits

Reviewing files that changed from the base of the PR and between 01eb38a and fb552f2.

📒 Files selected for processing (2)
  • src/bun.js/node/node_fs.zig
  • test/js/node/fs/fs.test.ts

Comment thread test/js/node/fs/fs.test.ts

@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 — correct, minimal fix that matches the existing async cleanup pattern; the flagged Dirent leak is pre-existing and can be folded in or handled separately.

Extended reasoning...

Overview

This PR fixes a heap corruption / double-free in two error-cleanup paths of fs.readdir (readdirWithEntries and readdirInner) when encoding: 'buffer' is used. The previous code called MarkedArrayBuffer.destroy() on structs stored by value inside entries.items. destroy() (array_buffer.zig:590-597) does allocator.free(buffer.slice()) followed by allocator.destroy(this) — the latter is only valid when this was created via MarkedArrayBuffer.init() (which heap-allocates the struct). Here, entries come from fromString() which returns by value, so this is an interior ArrayList pointer; for index 0 it equals entries.items.ptr, leading to UAF on the next iteration and a double-free in entries.deinit(). The fix replaces both sites with bun.default_allocator.free(item.buffer.byteSlice()), identical to the already-correct async cleanup at node_fs.zig:991. A regression test reproduces the path via a self-referential symlink triggering ELOOP and asserts the subprocess exits cleanly (previously SIGABRT under ASAN).

Security risks

None introduced. This is strictly a correctness fix on an error-handling path — it removes a heap corruption rather than adding any new attack surface. No user-controlled data flows change.

Level of scrutiny

Moderate — it's manual memory management in Zig, but the change is mechanical and self-evidently correct once you read MarkedArrayBuffer.destroy() / fromString() / init(). I verified: (1) fromString() returns by value with only the byte buffer heap-allocated, (2) MarkedArrayBuffer.slice() is an alias for buffer.byteSlice() so the freed slice is identical to what destroy() would have freed, and (3) the new code is byte-for-byte the same pattern as the existing async path at line 991.

Other factors

The bug-hunter flagged a pre-existing leak in the adjacent Dirent arm of the same switch (line 5014: result.name.deref() leaks result.path). That's a valid observation and a one-token fix (result.deref()), but it predates this PR, is a leak rather than a crash, and only fires on the withFileTypes: true recursive error path. It's a reasonable fold-in but not a blocker. The new test is well-constructed (subprocess isolation, ASAN symbolize=0 for fast abort, skipIf(isWindows) for symlink semantics).

Comment thread src/bun.js/node/node_fs.zig Outdated

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not correct. this will cause a crash when the Buffer came from JavaScript. Instead, we should call .destroy and MarkedArrayBuffer.destroy should be changed to not call allocator.destroy(this).

MarkedArrayBuffer is passed and stored by value (fromString/fromBytes/fromJS
all return by value); the struct is never individually heap-allocated by any
caller. Calling allocator.destroy(this) on a by-value instance passes an
interior pointer to the allocator - in the readdir error path this is an
ArrayList slot, corrupting the heap and double-freeing entries.items.ptr.

Remove the allocator.destroy(this) call and the unused init() constructor
(the only function that heap-allocated the struct, with zero callers). The
readdir call sites go back to plain .destroy(), which now correctly checks
this.allocator before freeing bytes so JS-owned buffers remain untouched.

Also release Dirent.path on the recursive-sync error path to match the
non-recursive and async cleanup sites.
@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked per review in 3b06a51:

  • MarkedArrayBuffer.destroy() no longer calls allocator.destroy(this) — it only frees the owned byte buffer, gated on this.allocator != null so JS-owned buffers stay untouched.
  • Removed the unused MarkedArrayBuffer.init() (the only constructor that heap-allocated the struct, zero callers) so dropping allocator.destroy(this) can't introduce a leak.
  • readdir call sites reverted to item.destroy() / result.destroy().
  • Also folded in the adjacent pre-existing leak in the same switch: result.name.deref()result.deref() so Dirent.path is released on the recursive-sync error path.

Gate re-verified locally against the branch base: unfixed → subprocess aborts (ASAN use-after-poison, exit 134); fixed → ELOOP, exit 0. zig:check-all passes on all targets.

@robobun robobun changed the title fs: free only byte slice when cleaning up readdir Buffer entries on error MarkedArrayBuffer.destroy: don't free the struct itself May 3, 2026

@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 — the revised fix to MarkedArrayBuffer.destroy() is cleaner than per-call-site frees, and the Dirent.path leak I flagged is now addressed via result.deref().

Extended reasoning...

Overview

This PR fixes a use-after-poison / double-free in the fs.readdirSync({encoding: 'buffer'}) error-cleanup path. The original symptom: MarkedArrayBuffer.destroy() called allocator.destroy(this) on a struct stored by-value inside an ArrayList, freeing the list's backing storage mid-iteration.

After my earlier review the author revised the approach: rather than inlining allocator.free(byteSlice()) at each readdir call site, commit 3b06a51 removes allocator.destroy(this) from MarkedArrayBuffer.destroy() itself and deletes the unused MarkedArrayBuffer.init() (the only constructor that ever heap-allocated the struct). The readdir call sites go back to calling .destroy(), which is now safe and retains the this.allocator null-check that protects JS-owned buffers. The same commit also fixes the pre-existing Dirent arm in readdirInner to call result.deref() instead of result.name.deref(), releasing both name and path — addressing the leak I flagged.

Security risks

None. This is an internal memory-management fix on an error path; no new inputs, parsing, auth, or external surface area.

Level of scrutiny

Moderate — it changes the semantics of a shared helper (MarkedArrayBuffer.destroy). I verified:

  • MarkedArrayBuffer.init had zero callers (grep confirms), so removing it is safe.
  • There are no allocator.create(MarkedArrayBuffer) or heap-allocated *MarkedArrayBuffer sites anywhere in the tree, so dropping allocator.destroy(this) cannot introduce a leak.
  • The only .destroy() callers are the two readdir cleanup loops (node_fs.zig:4565, 5013), both operating on by-value entries.items — exactly the case this fix targets.

Other factors

  • A regression test spawns a subprocess hitting the ELOOP path twice and asserts clean exit; the PR description shows it SIGABRTs under ASAN without the fix and passes with it.
  • The two CI failures (bake/dev-and-prod.test.ts on win-aarch64, fetch-http2-client.test.ts on linux-asan) are unrelated to fs/readdir.
  • CodeRabbit's remaining nit (assert stderr separately) is stylistic and non-blocking; debug builds can emit benign stderr and the test already pins behavior via stdout + exit code.

@Jarred-Sumner Jarred-Sumner merged commit 798542b into main May 3, 2026
47 of 56 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/8a31a42a/fix-readdir-buffer-error-cleanup branch May 3, 2026 03:15
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
## Problem

`MarkedArrayBuffer.destroy()` did two things:
```zig
allocator.free(content.buffer.slice()); // free the bytes
allocator.destroy(this);                 // free *this
```

Every constructor that is actually used (`fromString`, `fromBytes`,
`fromJS`, `fromTypedArray`, `fromArrayBuffer`) returns
`MarkedArrayBuffer` **by value**, so `this` is never an individually
heap-allocated struct — it's a stack local, an embedded field, or an
ArrayList slot. The `allocator.destroy(this)` call passes that interior
pointer to mimalloc.

In the readdir Buffer error-cleanup path (`readdirWithEntries` /
`readdirInner`), entries are appended by value via
`Buffer.fromString()`:
- `allocator.destroy(&entries.items[0])` frees `entries.items.ptr`
- the next loop iteration reads `this.*` from poisoned memory
- `entries.deinit()` frees the same pointer again

## Repro

```js
const fs = require('fs');
// dir contains regular files + a self-referential symlink 'loop -> loop'
fs.readdirSync(dir, { encoding: 'buffer', recursive: true });
```

The recursive walk collects Buffer entries for the root, then fails with
`ELOOP` opening the symlink (not in the swallowed `NOENT/NOTDIR/PERM`
set), and enters the cleanup loop. Under ASAN:

```
==3593==ERROR: AddressSanitizer: use-after-poison on address 0x737ec6e50040
READ of size 64 at 0x737ec6e50040 thread T0
    oven-sh#1 MarkedArrayBuffer.destroy          array_buffer.zig:591
    oven-sh#2 NodeFS.readdirInner                node_fs.zig:5013
    oven-sh#3 NodeFS.readdir                     node_fs.zig:4518
```

## Fix

- Drop `allocator.destroy(this)` from `MarkedArrayBuffer.destroy()`. The
struct is passed/stored by value; callers own its storage.
- Remove the unused `MarkedArrayBuffer.init()` (the only function that
heap-allocated the struct, zero callers) so there's no pairing that
would leak.
- The readdir call sites keep calling `.destroy()`, which still checks
`this.allocator` before freeing bytes — JS-owned buffers remain
untouched.

Also fixed the adjacent `Dirent` arm of the recursive-sync error
cleanup: `result.name.deref()` → `result.deref()` so `Dirent.path` is
released too (matching the non-recursive and async cleanup sites).

## Verification

New test in `test/js/node/fs/fs.test.ts` creates a temp dir with files +
a self-referential symlink, spawns a subprocess that calls
`readdirSync({encoding:'buffer', recursive:true})`, and asserts it
throws `ELOOP` and exits 0.

```
# without fix
(fail) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ...
  { exitCode: 134, stdout: "" }  # SIGABRT from ASAN

# with fix
(pass) readdirSync({encoding: 'buffer', recursive: true}) frees entries safely ... [1.5s]
  { exitCode: 0, stdout: "ELOOP" }
```

`zig:check-all` passes on all targets.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
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