-
Notifications
You must be signed in to change notification settings - Fork 5k
bun:ffi: fix double-free of base_name in linkSymbols error paths #29974
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 |
|---|---|---|
|
|
@@ -1222,10 +1222,9 @@ | |
|
|
||
| if (function.symbol_from_dynamic_library == null) { | ||
| const ret = global.toInvalidArguments("Symbol \"{s}\" is missing a \"ptr\" field. When using linkSymbols() or CFunction(), you must provide a \"ptr\" field with the memory address of the native function.", .{bun.asByteSlice(function_name)}); | ||
| for (symbols.values()) |*value| { | ||
| allocator.free(@constCast(bun.asByteSlice(value.base_name.?))); | ||
| value.arg_types.clearAndFree(allocator); | ||
| value.deinit(global); | ||
| } | ||
|
Check notice on line 1227 in src/bun.js/api/ffi.zig
|
||
| symbols.clearAndFree(allocator); | ||
| return ret; | ||
| } | ||
|
|
@@ -1243,20 +1242,18 @@ | |
| }; | ||
| switch (function.step) { | ||
| .failed => |err| { | ||
| // Build the error before deinit: `err.msg` is owned by `function.step` | ||
| // and `function` is one of `symbols.values()`. | ||
| const res = ZigString.init(err.msg).toErrorInstance(global); | ||
| for (symbols.values()) |*value| { | ||
| allocator.free(@constCast(bun.asByteSlice(value.base_name.?))); | ||
| value.arg_types.clearAndFree(allocator); | ||
| value.deinit(global); | ||
| } | ||
|
Check notice on line 1250 in src/bun.js/api/ffi.zig
|
||
|
Comment on lines
+1245
to
1250
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. 🟣 Pre-existing, but worth noting since this PR's new comment implies the ordering makes it safe: Extended reasoning...What the bug isBuilding the JS Code path
Why ordering alone doesn't helpThe PR's comment ("Build the error before deinit: Step-by-step proofTake the PR's own new test case: linkSymbols({ "not a valid C identifier!": { ptr: cb.ptr, args: [], returns: "void" } });
Status and scopeThis is pre-existing: the old code did exactly the same thing (
Flagging it here because the PR rewrites these exact lines for memory-safety reasons and the new comment reads as though the lifetime issue is handled. FixEither of:
|
||
|
|
||
| const res = ZigString.init(err.msg).toErrorInstance(global); | ||
| function.deinit(global); | ||
| symbols.clearAndFree(allocator); | ||
| return res; | ||
| }, | ||
| .pending => { | ||
| for (symbols.values()) |*value| { | ||
| allocator.free(@constCast(bun.asByteSlice(value.base_name.?))); | ||
| value.arg_types.clearAndFree(allocator); | ||
| value.deinit(global); | ||
| } | ||
| symbols.clearAndFree(allocator); | ||
| return ZigString.static("Failed to compile (nothing happend!)").toErrorInstance(global); | ||
|
|
||
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, but worth noting since the PR description cites
dlopen's cleanup as the model:FFI.open()still has the same leak class in two error paths. Thedylib.lookup() orelsebranch (~ffi.zig:1110-1118) uses the manualfree(base_name); arg_types.clearAndFree()pattern instead ofvalue.deinit(global), leaking theTCC.Stateof any previously-compiled symbol; and the.failedbranch (~ffi.zig:1138-1146) registers thedeinitloop withdeferbut then callssymbols.clearAndFree()inline, so the deferred loop runs over an already-empty map and frees nothing. Both are one-line fixes identical in spirit to thelinkSymbolschanges here and would be a natural addition to this PR.Extended reasoning...
What the bug is
This PR fixes the error-cleanup paths in
FFI.linkSymbols()by replacing the manualfree(base_name); arg_types.clearAndFree()pattern withvalue.deinit(global), and the PR description explicitly cites "the equivalent cleanup indlopen" as the model. However,FFI.open()(thedlopenimplementation) still contains two error paths with the same leak class that this PR fixes:The
dylib.lookup() orelsebranch (around src/bun.js/api/ffi.zig:1110-1118) still uses the manual-free pattern:This frees
base_nameandarg_typesbut never callsFunction.deinit(), so it never freesstate(theTCC.State) orstep.failed.msg.The
.failedbranch (around src/bun.js/api/ffi.zig:1138-1146) does calldeinit, but viadefer:In Zig, the
deferbody runs at scope exit — aftersymbols.clearAndFree()has already executed inline. By the time the deferred loop runs,symbols.values()returns an empty slice, so the loop iterates zero times and nothing is freed.Code path that triggers it
The
open()loop processes each symbol completely (lookup →compile()→ switch onstep) before moving to the next iteration.Function.compile()allocates aTCC.Stateand stores it infunction.stateon success.Step-by-step proof for case (1) —
dlopen(lib, { good: {...}, missing: {...} })wheregoodexists in the library andmissingdoes not:dylib.lookup("good")succeeds →good.compile()runs → allocates aTCC.Stateintogood.state→good.step = .compiled.dylib.lookup("missing")returnsnull→ enters theorelsebranch.base_name+arg_typesfor each.good.state(a liveTCC.State) is never freed → leaked.Step-by-step proof for case (2) —
dlopen(lib, { good: {...}, bad: {...} })where both resolve in the library but TinyCC compilation ofbadfails (e.g.handleTCCErrorsetsbad.step = .failed):goodis looked up and compiled →good.stateholds aTCC.State.badis looked up,compile()runs, TinyCC reports an error →bad.step = .failedwith an allocatederr.msg..failedarm registersdefer for (symbols.values()) |*f| f.deinit(global);.resis built fromerr.msg.symbols.clearAndFree()runs inline — the map's backing arrays are freed andvalues()is now empty.return res;triggers thedefer.symbols.values()returns a zero-length slice, so thedeinitloop body never executes.good.base_name,good.arg_types,good.state(TCC.State),bad.base_name,bad.arg_types, andbad.step.failed.msgall leak.Why existing code doesn't prevent it
For (1), the adjacent
compile() catch,.failed, and.pendingbranches inopen()all already callvalue.deinit(global)— thedylib.lookup() orelsebranch is simply the one that was missed when the others were converted. For (2), the code appears correct at a glance because it does calldeiniton every value, but Zig'sdefersemantics mean the loop's iteration source (symbols.values()) is evaluated at scope-exit time, afterclearAndFree()has emptied it. This is exactly the ordering bug this PR avoids in thelinkSymbols.failedbranch by running thedeinitloop inline (after buildingres, beforeclearAndFree).Impact
Each leaked
TCC.Stateis a full TinyCC compiler state (relocated code, symbol tables, etc.) — typically tens of KB or more per symbol. In long-running processes that probe for symbols or repeatedly attemptdlopenagainst libraries with partial symbol coverage, this accumulates. The.failedpath additionally leaks everybase_name, everyarg_typesbuffer, and the duplicated TinyCC error message string. These are not crashes or correctness bugs in user-visible behavior — they are pure memory leaks on the error path — but they are the same class of bug this PR is explicitly fixing inlinkSymbols.How to fix
Both are one-line changes mirroring this PR's
linkSymbolsedits:dylib.lookup() orelsebranch: replace the loop body withvalue.deinit(global);(identical to the change at lines 1225-1227 of this PR)..failedbranch: drop thedeferand run thedeinitloop inline after buildingresbut beforesymbols.clearAndFree():linkSymbols.failedbranch at lines 1244-1252.These are pre-existing — the PR doesn't touch
FFI.open()— but since the PR description holdsdlopenup as the reference implementation, it's worth flagging that the reference has the same bug.