fix(ffi): externalize typed array backing stores to prevent GC heap corruption#28259
fix(ffi): externalize typed array backing stores to prevent GC heap corruption#28259
Conversation
…orruption When a small Buffer (e.g. Buffer.alloc(96)) is passed to an FFI function, JSC may store the buffer's data inline in the GC heap (FastTypedArray mode). If the FFI function writes past the buffer's end, it corrupts JSC's GC metadata, causing segfaults in unrelated operations like Buffer.alloc or GC marking cycles. This fix calls JSC's possiblySharedBuffer() to externalize the typed array's backing store before extracting its pointer for FFI. For FastTypedArray, this moves data from the GC heap to separately-allocated memory via slowDownAndWasteMemory(). For already-external arrays, it's a no-op. The externalization is applied in all FFI pointer extraction paths: - JSVALUE_TO_TYPED_ARRAY_VECTOR in FFI.h (TCC-compiled wrappers) - ptrWithoutTypeChecks in FFIObject.zig (fast path) - ptr_ in FFIObject.zig (slow path) Closes #23446 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Updated 2:24 PM PT - Mar 18th, 2026
❌ Your commit
🧪 To try this PR locally: bunx bun-pr 28259That installs a local version of the PR into your bun-28259 --bun |
WalkthroughAdded a new FFI entrypoint, Bun__FFI__ensureExternalBackingStore, and wired it through headers, Zig bindings, and C++ implementation to ensure typed-array backing stores are externalized before exposing pointers to FFI; added a regression test exercising FFI small-buffer externalization and GC resilience for issue Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 📝 Coding Plan
Comment |
There was a problem hiding this comment.
The approach looks correct — possiblySharedBuffer() is the standard JSC API for externalizing FastTypedArray inline storage, and it's already used elsewhere in the codebase. However, this touches FFI/GC interaction across C++, Zig, and TCC-compiled C, which warrants a human familiar with JSC internals confirming the fix.
Extended reasoning...
Overview
This PR fixes a GC heap corruption crash when small Buffer objects (stored inline as JSC FastTypedArrays) are passed to FFI functions. The fix calls possiblySharedBuffer() on typed array views before extracting their data pointer, which forces JSC to externalize inline storage. Changes span 5 files: a new C++ function in ffi.cpp, modifications to FFI.h (TCC-compiled C), FFIObject.zig (fast and slow pointer extraction paths), ffi.zig (TCC symbol injection), and a new regression test.
Security risks
This is a security improvement. The original code allowed FFI buffer overflows to corrupt JSC GC metadata (inline heap cells). After this fix, data is stored in external allocations, so overflows affect separately-allocated memory instead of GC internals. No new attack surface is introduced.
Level of scrutiny
Medium-high. While the fix uses well-established JSC APIs (possiblySharedBuffer() / vector()) already used in 5+ other places in the codebase, the change modifies a core assumption about how FFI extracts pointers from typed arrays. The interaction between TCC-compiled wrappers, Zig fast paths, and JSC GC internals is subtle enough that a human familiar with these systems should confirm the approach — particularly that externalizing at pointer-extraction time (rather than buffer creation) is the right layer.
Other factors
- The PR has a
claudelabel indicating AI authorship with no human review yet - The regression test is thorough (5 tests, GC stress testing with 5000 iterations)
- No CODEOWNERS cover these paths
possiblySharedBuffer()for already-external arrays is documented as a no-op, so performance impact should be minimal for the common case
TinyCC cannot find system headers (string.h) on Windows CI. Skip the test on Windows entirely (matching cc.test.ts pattern) and replace memset with a manual loop to avoid the include. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/regression/issue/23446.test.ts`:
- Around line 7-10: The skip predicate passed to describe.skipIf is redundant
because isFFIUnavailable is defined as isWindows && isArm64 and therefore
already covered by checking isWindows; update the call to describe.skipIf to
remove the redundant "|| isFFIUnavailable" term so the predicate becomes
describe.skipIf(isASAN || isWindows)("FFI small buffer externalization", ...) —
adjust the predicate expression where describe.skipIf is used and keep the
isFFIUnavailable const only if still needed elsewhere (or remove its declaration
if unused).
- Around line 28-53: The test uses an order-dependent fixture by initializing
lib inside it("setup") (using cc and tempDirWithFiles) and then relying on that
shared lib in later tests; make setup deterministic by moving
initialization/cleanup into beforeAll/afterAll (create dir and assign lib via cc
with the same symbols), or convert each dependent test to be self-contained by
calling tempDirWithFiles and cc inside each test before assertions; ensure any
teardown mirrors creation so lib is never undefined when tests run individually.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: b64ae8ea-9ca6-4a25-ba2f-8bdb133d1dfb
📒 Files selected for processing (1)
test/regression/issue/23446.test.ts
Address review feedback: - Move setup/cleanup from it() blocks to beforeAll/afterAll - Remove redundant isFFIUnavailable from skip predicate since isWindows already covers Windows ARM64 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 `@test/regression/issue/23446.test.ts`:
- Around line 52-54: The test's afterAll handler calls lib.close() without
guarding against lib being undefined if beforeAll failed; update the afterAll
block to check that lib is defined (e.g., if (lib) or using optional chaining
like lib?.close()) before calling close so it won't throw when lib was never
initialized; reference the afterAll callback and the lib variable when making
the change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 434696c0-79ac-467f-b99f-682429a6eb59
📒 Files selected for processing (1)
test/regression/issue/23446.test.ts
| afterAll(() => { | ||
| lib.close(); | ||
| }); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Add defensive null check in afterAll
If beforeAll fails (e.g., cc() throws during compilation), lib remains undefined and lib.close() will throw, potentially masking the original failure.
🛡️ Suggested fix
afterAll(() => {
- lib.close();
+ lib?.close();
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| afterAll(() => { | |
| lib.close(); | |
| }); | |
| afterAll(() => { | |
| lib?.close(); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@test/regression/issue/23446.test.ts` around lines 52 - 54, The test's
afterAll handler calls lib.close() without guarding against lib being undefined
if beforeAll failed; update the afterAll block to check that lib is defined
(e.g., if (lib) or using optional chaining like lib?.close()) before calling
close so it won't throw when lib was never initialized; reference the afterAll
callback and the lib variable when making the change.
Use optional chaining in afterAll to avoid throwing if beforeAll failed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| state.addSymbol("JSVALUE_TO_UINT64_SLOW", workaround.JSVALUE_TO_UINT64) catch unreachable; | ||
| state.addSymbol("INT64_TO_JSVALUE_SLOW", workaround.INT64_TO_JSVALUE) catch unreachable; | ||
| state.addSymbol("UINT64_TO_JSVALUE_SLOW", workaround.UINT64_TO_JSVALUE) catch unreachable; | ||
| state.addSymbol("Bun__FFI__ensureExternalBackingStore", &Offsets.Bun__FFI__ensureExternalBackingStore) catch unreachable; |
There was a problem hiding this comment.
🟡 Nit: JSArrayBufferView__offsetOfVector is now dead code. The #define is still injected into TCC-compiled code via CompilerRT.define() (line 2401), but after this PR changed JSVALUE_TO_TYPED_ARRAY_VECTOR to call Bun__FFI__ensureExternalBackingStore, no code in FFI.h references it. Consider removing the define injection and the corresponding Offsets struct field as cleanup.
Extended reasoning...
What the bug is
The CompilerRT.define() function in ffi.zig (line 2401) still injects a #define JSArrayBufferView__offsetOfVector into TCC-compiled code. However, the only consumer of this define was the old JSVALUE_TO_TYPED_ARRAY_VECTOR implementation which directly read the vector pointer at this offset:
// OLD:
return *(void**)((char*)val.asPtr + JSArrayBufferView__offsetOfVector);
// NEW:
return Bun__FFI__ensureExternalBackingStore(val);Grepping FFI.h for JSArrayBufferView__offsetOfVector returns zero matches, confirming it is unused.
Why this happens
This PR correctly changed all typed array pointer extraction paths to go through Bun__FFI__ensureExternalBackingStore to prevent GC heap corruption. The new function is properly injected via CompilerRT.inject(). However, the old JSArrayBufferView__offsetOfVector define was not removed from CompilerRT.define(), and the corresponding field in the Offsets struct and its initialization in ffi.cpp remain.
Impact assessment
This is harmless dead code:
- An unused
#definehas zero runtime cost (it is a preprocessor symbol that never gets expanded) - The
Offsetsstruct field initialization is a singleu32read alongside other still-needed fields, so the overhead is negligible - It does not cause incorrect behavior, crashes, or any observable issue
One consideration: these defines are technically in scope for user C code compiled via cc(), so a user could theoretically reference JSArrayBufferView__offsetOfVector directly. However, this would be relying on an undocumented internal detail, and the offset alone is no longer sufficient to safely extract a typed array vector (which is precisely what this PR fixes).
Concrete walkthrough
- User calls
cc({ source: "test.c", symbols: {...} }) CompilerRT.define(state)runs, injecting#define JSArrayBufferView__offsetOfVector <value>into the TCC compilation context- The user's code and FFI.h are compiled together
- No code in FFI.h references
JSArrayBufferView__offsetOfVector—JSVALUE_TO_TYPED_ARRAY_VECTORnow callsBun__FFI__ensureExternalBackingStoreinstead - The define sits unused in the preprocessor symbol table
Suggested fix
Remove the JSArrayBufferView__offsetOfVector entry from the defineSymbolsComptime call in CompilerRT.define(). Optionally, also remove the JSArrayBufferView__offsetOfVector field from the Offsets struct and its initialization in ffi.cpp, though leaving it is equally harmless.
Summary
Bufferto an FFI function and overflowing it corrupts JSC's GC heap, causing segfaults in unrelated operationspossiblySharedBuffer()/slowDownAndWasteMemory()Problem
When
Buffer.alloc(N)creates a small buffer, JSC uses "FastTypedArray" mode where data is stored inline in the GC heap cell. If an FFI function writes past the buffer's end (e.g., writing a 192-byteXEventinto a 96-byte buffer), it corrupts JSC's GC metadata, causing crashes like:Fix
Before extracting a typed array's pointer for FFI, call
possiblySharedBuffer()which internally callsslowDownAndWasteMemory()forFastTypedArraymode. This moves data from the GC heap to separately-allocated external memory. For already-external arrays, it's a no-op.Applied in all FFI pointer extraction paths:
JSVALUE_TO_TYPED_ARRAY_VECTORinFFI.h(TCC-compiled wrappers viadlopen)ptrWithoutTypeChecksinFFIObject.zig(fast path forptr())ptr_inFFIObject.zig(slow path)Test plan
test/regression/issue/23446.test.ts(5 tests, 10038 assertions)cc.test.ts,ffi-error-messages.test.ts,addr32.test.tsCloses #23446
🤖 Generated with Claude Code