Skip to content

Free FFI struct and native resources in finalize() - #29858

Closed
robobun wants to merge 4 commits into
mainfrom
farm/462d1221/ffi-finalize-leak
Closed

Free FFI struct and native resources in finalize()#29858
robobun wants to merge 4 commits into
mainfrom
farm/462d1221/ffi-finalize-leak

Conversation

@robobun

@robobun robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

What

FFI.finalize() at src/bun.js/api/ffi.zig:69 was an empty function:

pub fn finalize(_: *FFI) callconv(.c) void {}

ffi.classes.ts sets finalize: true, so the generated C++ destructor calls into this Zig function expecting it to free the native struct and any owned resources. Since it did nothing:

  • the heap FFI struct (from bun.new(FFI, ...)) was leaked on every GC of a dlopen() / cc() / linkSymbols() result
  • if the user never called .close() first, the dylib handle, the shared TCC state, and every per-symbol TCC compiler state + arg_types array leaked as well

Repro

const { linkSymbols, JSCallback } = require("bun:ffi");
const cb = new JSCallback(() => 42, { returns: "int32_t", args: [] });
for (let i = 0; i < 20000; i++) {
  linkSymbols({ fn: { returns: "int32_t", args: [], ptr: cb.ptr } });
  if (i % 100 === 0) Bun.gc(true);
}

RSS grows ~315 MB on release / ~230 MB on debug+ASAN over 20k calls.

Fix

Release resources in finalize()

  • Extract the cleanup from close() into a private deinit() (same dylib/TCC/functions teardown, guarded by closed).
  • finalize() now calls deinit() then bun.destroy(this).
  • Drop the unused globalThis parameter from Function.deinit() (it was literally _ = globalThis;) so it can be invoked from the finalizer.
  • Switch the cc() allocation site from default_allocator.create(FFI) to bun.new(FFI, ...) so it pairs with bun.destroy().

Keep the wrapper alive while any symbol is reachable

Tearing down TCC/dylib memory is only safe once every JSFFIFunction that points into it is unreachable, but the documented pattern const { symbols: { fn } } = dlopen(...) drops the wrapper immediately. JSFFIFunction held only a raw entry-point pointer with no GC edge back to the wrapper, so GC could run finalize() and unmap the trampoline while fn was still callable (segfaulted test/napi/napi-value-ffi.test.ts).

  • Add WriteBarrier<JSObject> m_owner to JSFFIFunction, visited in visitChildren.
  • After lib.toJS() in each of dlopen/cc/linkSymbols, point every compiled symbol's m_owner at the FFI wrapper via a new Bun__FFIFunction_setOwner.
  • The wrapper is now kept alive as long as any extracted symbol is; finalize() only runs once the last one is collected.

Verification

test/js/bun/ffi/ffi-leak.test.ts has two tests:

  1. churns 2000 linkSymbols() calls with periodic GC and asserts RSS growth < 25 MB (before: >30 MB release / >100 MB debug+ASAN; after: <10 MB)
  2. extracts a symbol, drops the wrapper, forces GC, then calls the symbol 100× — this segfaulted without the owner edge

BUN_DEBUG_alloc=1 shows a matching destroy(FFI) for every new(FFI), and the wrapper survives GC while an extracted symbol is held:

[alloc] new(FFI) = ...@70e85d0b0050
result: 42
[alloc] destroy(FFI) = ...@70e85d0b0050   // only after fn is dropped

All existing test/js/bun/ffi/* tests pass.

@robobun

robobun commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:36 AM PT - May 4th, 2026

@autofix-ci[bot], your commit 87e7906 has 3 failures in Build #51174 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29858

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

bun-29858 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun:ffi leaks memory #20845 - Reports bun:ffi leaking memory on repeated FFI usage; commenters confirmed unbounded RSS growth when repeatedly opening/closing libraries — exactly the leak fixed by implementing finalize() to free the FFI struct, dylib handle, and TCC state on GC

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

Fixes #20845

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@dylan-conway has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 38 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4c987c28-ad3b-4084-8fda-3c8140667513

📥 Commits

Reviewing files that changed from the base of the PR and between 088e472 and 62de6ec.

📒 Files selected for processing (4)
  • src/bun.js/api/ffi.zig
  • src/bun.js/bindings/JSFFIFunction.cpp
  • src/bun.js/bindings/JSFFIFunction.h
  • test/js/bun/ffi/ffi-leak.test.ts

Walkthrough

The changes centralize FFI resource lifetime management by consolidating deinit logic, updating FFI construction to use bun.new, simplifying Function.deinit signature by removing the globalThis parameter, and adding a new leak regression test.

Changes

Cohort / File(s) Summary
FFI Lifetime Management
src/bun.js/api/ffi.zig
Refactored resource cleanup: finalize now delegates to deinit which handles teardown of dylib, shared_state, and functions map. FFI construction switched to bun.new(). Updated all Function.deinit call sites to remove globalThis argument; simplified Function.deinit signature and unconditional js_function clearing.
FFI Leak Testing
test/js/bun/ffi/ffi-leak.test.ts
Added new integration-style leak regression test that spawns a child Bun process to create JSCallback, repeatedly call linkSymbols(), and measure RSS growth under 30MB threshold with 60s timeout.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: freeing FFI struct and native resources in the finalize() function, which is the core fix for the memory leak described in the PR.
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.
Description check ✅ Passed The PR description comprehensively covers what was fixed, why, and how it was verified with specific examples and test results.

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


Review rate limit: 0/5 reviews remaining, refill in 4 minutes and 38 seconds.

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 `@src/bun.js/api/ffi.zig`:
- Around line 74-94: FFI.deinit is double-deinitializing the shared TCC.State
because each Function still holds the same state pointer and Function.deinit()
always calls state.deinit(); fix this by ensuring only the FFI owner
deinitializes the shared_state: before calling each Function.deinit() in
FFI.deinit (and in the error-cleanup paths used by open()/linkSymbols()), null
out or clear the Function.state pointers (or flip an ownership flag on each
Function) so Function.deinit() does not call state.deinit() for the shared
TCC.State; alternatively, add an owned boolean on TCC.State/Function and only
call state.deinit() when owned is true (ensure cc() marks the moved-in state as
owned by FFI and not by individual Functions).
🪄 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: 470be458-a43b-46b9-ab58-a930f438b73d

📥 Commits

Reviewing files that changed from the base of the PR and between 97e4b3f and 088e472.

📒 Files selected for processing (2)
  • src/bun.js/api/ffi.zig
  • test/js/bun/ffi/ffi-leak.test.ts

Comment thread src/runtime/ffi/ffi.zig
Comment thread src/runtime/ffi/ffi.zig
Comment thread test/js/bun/ffi/ffi-leak.test.ts Outdated
Comment thread test/js/bun/ffi/ffi-leak.test.ts Outdated
Comment thread test/js/bun/ffi/ffi-leak.test.ts Outdated
robobun added 2 commits May 4, 2026 10:28
FFI.finalize() was empty, so every dlopen()/cc()/linkSymbols() result
leaked its heap-allocated FFI struct on GC. If the user never called
.close(), the dylib handle, shared TCC state, and per-symbol compiler
state + arg_types arrays leaked as well.

Extract the cleanup from close() into deinit() (same logic, now also
called from finalize() followed by bun.destroy()). Drop the unused
globalThis parameter from Function.deinit() so it can run from the
finalizer. Switch the cc() allocation site to bun.new() to pair with
bun.destroy().
The first commit made finalize() tear down the dylib / TCC state. That
is only safe once every JSFFIFunction that points into that memory is
unreachable. The documented pattern

    const { symbols: { fn } } = dlopen(...)

drops the wrapper immediately, so GC could run finalize() and unmap
the trampoline while fn is still callable, which segfaulted
test/napi/napi-value-ffi.test.ts in CI.

Give JSFFIFunction a WriteBarrier<JSObject> m_owner visited in
visitChildren, and set it to the FFI wrapper for every compiled symbol
right after the wrapper is created in dlopen/cc/linkSymbols. The
wrapper is now kept alive for as long as any extracted symbol is, and
finalize() only runs once the last symbol has been collected.

Also add a regression test that extracts a symbol, forces GC, and
calls it repeatedly.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/462d1221/ffi-finalize-leak branch from 62de6ec to 898bda7 Compare May 4, 2026 10:28
robobun and others added 2 commits May 4, 2026 11:13
TinyCC (and therefore JSCallback/linkSymbols) is unavailable on
Windows aarch64, so the spawned child produces no output and the
assertions fail. Gate both tests behind isWindows && isArm64, matching
cc.test.ts and ffi-error-messages.test.ts.
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as obsolete. This was written against src/bun.js/api/ffi.zig before the Rust port (#30412); the Zig file is now a non-compiled porting reference, so these changes can no longer take effect. The finalize() semantics were since settled in the Rust implementation as an intentional leak when the library was never closed (see the comment in src/runtime/ffi/ffi_body.rs, added in #30875), and the close()-related use-after-free this overlapped with is fixed by #31960.

@robobun robobun closed this Jun 7, 2026
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