Free FFI struct and native resources in finalize() - #29858
Conversation
|
Updated 11:36 AM PT - May 4th, 2026
❌ @autofix-ci[bot], your commit 87e7906 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 29858That installs a local version of the PR into your bun-29858 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
WalkthroughThe changes centralize FFI resource lifetime management by consolidating deinit logic, updating FFI construction to use Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ 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 |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/bun.js/api/ffi.zigtest/js/bun/ffi/ffi-leak.test.ts
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.
62de6ec to
898bda7
Compare
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.
|
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. |
What
FFI.finalize()atsrc/bun.js/api/ffi.zig:69was an empty function:ffi.classes.tssetsfinalize: 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:FFIstruct (frombun.new(FFI, ...)) was leaked on every GC of adlopen()/cc()/linkSymbols()result.close()first, the dylib handle, the shared TCC state, and every per-symbol TCC compiler state +arg_typesarray leaked as wellRepro
RSS grows ~315 MB on release / ~230 MB on debug+ASAN over 20k calls.
Fix
Release resources in
finalize()close()into a privatedeinit()(same dylib/TCC/functions teardown, guarded byclosed).finalize()now callsdeinit()thenbun.destroy(this).globalThisparameter fromFunction.deinit()(it was literally_ = globalThis;) so it can be invoked from the finalizer.cc()allocation site fromdefault_allocator.create(FFI)tobun.new(FFI, ...)so it pairs withbun.destroy().Keep the wrapper alive while any symbol is reachable
Tearing down TCC/dylib memory is only safe once every
JSFFIFunctionthat points into it is unreachable, but the documented patternconst { symbols: { fn } } = dlopen(...)drops the wrapper immediately.JSFFIFunctionheld only a raw entry-point pointer with no GC edge back to the wrapper, so GC could runfinalize()and unmap the trampoline whilefnwas still callable (segfaultedtest/napi/napi-value-ffi.test.ts).WriteBarrier<JSObject> m_ownertoJSFFIFunction, visited invisitChildren.lib.toJS()in each ofdlopen/cc/linkSymbols, point every compiled symbol'sm_ownerat the FFI wrapper via a newBun__FFIFunction_setOwner.finalize()only runs once the last one is collected.Verification
test/js/bun/ffi/ffi-leak.test.tshas two tests:linkSymbols()calls with periodic GC and asserts RSS growth < 25 MB (before: >30 MB release / >100 MB debug+ASAN; after: <10 MB)BUN_DEBUG_alloc=1shows a matchingdestroy(FFI)for everynew(FFI), and the wrapper survives GC while an extracted symbol is held:All existing
test/js/bun/ffi/*tests pass.