bun:ffi: keep ptr(typedArray) valid across DFG tier-up - #32055
Conversation
|
Updated 10:45 AM PT - Jun 28th, 2026
❌ @robobun, your commit 87174b5 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 32055That installs a local version of the PR into your bun-32055 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
WalkthroughPrevents dangling FFI pointers from typed arrays by stabilizing their backing storage before pointer capture. Adds a JSC FFI binding to force ChangesFFI Typed Array Pointer Stability
Possibly related issues
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 Infer (1.2.0)src/jsc/bindings/bindings.cppIn file included from src/jsc/bindings/bindings.cpp:10: ... [truncated 2200 characters] ... -52 Comment |
|
Checked whether #31941 is the same bug before linking it: it isn't provably so, and the evidence leans against. The actual OpenCode guard (packages/tui/src/terminal-win32.ts, The crash signature in #31941 (frame inside an unsymbolized module above One honest caveat: with this PR, the first |
…ress ptr(typedArray) returned the raw vector address of the view without forcing the view out of FastTypedArray mode. JSC relocates that vector when the view transitions to wasteful mode, and the engine triggers the transition on its own: DFG tier-up folds the view into compiled code and registers an ArrayBufferView watchpoint, whose installation calls possiblySharedBuffer() (slowDownAndWasteMemory copies the storage into a fresh ArrayBuffer and repoints m_vector). From that moment the captured pointer dangles: native writes land in the abandoned allocation while JS reads the new one. ptr() now forces the one-time fast-to-wasteful transition before reading the address, so the pointer it hands out can never be invalidated by the engine. Other storage modes already have immovable data and are left untouched. Fixes #32054
6bbdade to
1571a72
Compare
There was a problem hiding this comment.
I didn't find any issues — the fix is small and mirrors the existing borrowBytesForOffThread/pinArrayBuffer pattern — but since this is a memory-safety change at the FFI/JSC-JIT boundary, it's worth a human sanity check on the typed-array storage-mode assumptions.
Extended reasoning...
Overview
This PR fixes #32054: bun:ffi's ptr(typedArray) could return an address that goes stale once DFG tier-up relocates a FastTypedArray's backing vector. The fix adds a new C++ binding JSC__JSValue__ensureStableTypedArrayVector (bindings.cpp) plus a Rust wrapper (JSValue.rs), and calls it from ptr_() in FFIObject.rs before reading the vector address. A subprocess regression test in ffi.test.js forces early tier-up and asserts the captured address remains valid. Net new logic is ~9 lines; the rest is comments and the test.
Security risks
This is a memory-safety fix (eliminates a dangling pointer / write-to-abandoned-GC-allocation hazard). It does not introduce new attack surface — bun:ffi is already an inherently unsafe, trusted API. No injection/auth/data-exposure concerns. The only new failure mode is an OOM throw if possiblySharedBuffer() fails to allocate, which is the correct behavior.
Level of scrutiny
Medium-high. The diff is tiny and follows an established in-tree pattern (the adjacent pinArrayBuffer / borrowBytesForOffThread helpers in bindings.cpp do the same possiblySharedBuffer() materialization for the same reason, and the PR description explicitly cross-references them). I verified that the only live code path for ptr() is FFI__ptr__put → FFI__ptr__slowpathWrapper → ptr_() (the DOMJIT fastpath in ZigGeneratedCode.cpp is commented out), so the fix covers all current callers. That said, correctness here hinges on JSC-internals assumptions about typed-array storage-mode transitions and JIT watchpoint installation — knowledge I can verify against the comments but not against JSC source itself.
Other factors
No bugs were flagged by the bug-hunting system, no outstanding reviewer comments, no relevant CODEOWNERS entries. The root-cause analysis in the description is unusually thorough and the regression test is deterministic. I'm deferring rather than approving solely because FFI + JIT-internals + raw-pointer lifetime is exactly the kind of change where a human with JSC expertise should confirm the storage-mode reasoning (and note whether the dormant DOMJIT fastpath would need the same treatment if re-enabled).
|
Thanks for the careful read. Deferring to a human on the JSC storage-mode reasoning is fair, so here are the specifics a reviewer would want to confirm, plus a direct answer on the dormant DOMJIT fastpath. Dormant DOMJIT fastpath: good catch, and the answer is yes. Storage-mode assumptions to confirm:
|
|
Status: the diff is green and ready for review/merge. The red CI is entirely unrelated flakes and infra on lanes this change does not touch. This PR only adds a guard inside Failures seen on the CI runs for this branch, none of which execute the changed code path:
I have used my one CI re-roll on this PR. A maintainer re-run should clear the flaky/infra lanes. Happy to rebase again if needed. |
|
Leaving this open while closing the other pre-#35246 On The The test in this PR reproduces it without a native library ( |
Fixes #32054
bun:ffi'sptr(typedArray)goes stale once the calling function is DFG-compiled: native code keeps writing through the captured address while JS reads of the typed array return frozen values. Reported with out-param C APIs on macOS arm64; reproduced on Linux x64 with the issue's exact repro:Cause
Not DFG load elimination (the issue's suspicion): the typed array's backing store is physically relocated at tier-up, and the pointer captured by
ptr()dangles.A small typed array (
new Float64Array(1)) is a JSCFastTypedArraywhose vector lives in the GC heap.ptr()returned that raw vector address without forcing the view out of fast mode. When DFG compiles the hot caller, it folds the view into the compiled code and registers an ArrayBufferView watchpoint; registration callspossiblySharedBuffer()->slowDownAndWasteMemory(), which copies the storage into a freshArrayBufferand repointsm_vector. From that moment the captured address points at the abandoned allocation: native writes land there (a heap-corruption hazard once the GC reuses it), while JS indexing reads the new vector, frozen at tier-up-time contents. This explains every observation in the issue, includingread.f64(ptr)"working" (it reads the abandoned block where the native writes land) and the onset tracking tier-up thresholds. Confirmed by printingptr(out)again at first mismatch: the address changes exactly at tier-up.Fix
ptr()now forces the one-time fast-to-wasteful transition (possiblySharedBuffer()) before reading the address, so the pointer it hands out can never be invalidated by the engine. The fixing line is theensure_stable_typed_array_vector()call inptr_(src/runtime/ffi/FFIObject.rs); the rest is the C++ helper and its Rust binding. Cost: a one-time copy of at mostfastSizeLimit(1000) elements on the firstptr()call per array. Every other storage mode already has immovable data and is untouched (Oversize is adopted in place, Wasteful/DataView already have an ArrayBuffer). Per-call argument marshaling reads the vector fresh at call time and was never affected.This is the same hazard
src/runtime/image/Image.rsandJSC__JSValue__borrowBytesForOffThreadalready document and handle for their own pointer captures.Verification
New test
ptr(typedArray) stays valid after DFG tier-upintest/js/bun/ffi/ffi.test.jsspawns a subprocess with deterministic early tier-up (useConcurrentJIT=false, low optimize thresholds), hot-loops over the view, then assertsptr(out)is unchanged and that a JS write is visible through the captured address. It fails on the unfixed build (MOVED: the view's storage was relocated after ptr() was taken, also reproducible on bun 1.3.x/1.4 releases) and passes with the fix. The issue's original C repro (100k native out-param writes) passes on the fixed build:OK: no stale reads, at default thresholds and with forced early tier-up.Noted while testing, not part of this change: the
run ffisuite inffi.test.jsdlopens a hardcoded/tmp/bun-ffi-test.dylib(line ~381), so it can't run on Linux even when the helper lib is compiled; CI never compiles the lib, so the suite is skip-only there. Running it locally also surfaces a pre-existing ASAN bad-free in thetoBuffer(ptr, 0, 4)no-deallocator path freeing static library memory at GC (the footgun already documented inFFIObject.rsre: PR #31753), reproducible without this change.