Skip to content

bun:ffi: keep ptr(typedArray) valid across DFG tier-up - #32055

Open
robobun wants to merge 2 commits into
mainfrom
farm/cdacbf14/ffi-ptr-stable-vector
Open

bun:ffi: keep ptr(typedArray) valid across DFG tier-up#32055
robobun wants to merge 2 commits into
mainfrom
farm/cdacbf14/ffi-ptr-stable-vector

Conversation

@robobun

@robobun robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #32054

bun:ffi's ptr(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:

iter=17571: C wrote 571.5; memory actually contains 571.5; JS reads out[0] = 570.5
FAIL: 82347/100000 stale typed-array reads (first at iter 17571)

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 JSC FastTypedArray whose 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 calls possiblySharedBuffer() -> slowDownAndWasteMemory(), which copies the storage into a fresh ArrayBuffer and repoints m_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, including read.f64(ptr) "working" (it reads the abandoned block where the native writes land) and the onset tracking tier-up thresholds. Confirmed by printing ptr(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 the ensure_stable_typed_array_vector() call in ptr_ (src/runtime/ffi/FFIObject.rs); the rest is the C++ helper and its Rust binding. Cost: a one-time copy of at most fastSizeLimit (1000) elements on the first ptr() 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.rs and JSC__JSValue__borrowBytesForOffThread already document and handle for their own pointer captures.

Verification

New test ptr(typedArray) stays valid after DFG tier-up in test/js/bun/ffi/ffi.test.js spawns a subprocess with deterministic early tier-up (useConcurrentJIT=false, low optimize thresholds), hot-loops over the view, then asserts ptr(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 ffi suite in ffi.test.js dlopens 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 the toBuffer(ptr, 0, 4) no-deallocator path freeing static library memory at GC (the footgun already documented in FFIObject.rs re: PR #31753), reproducible without this change.

@robobun

robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:45 AM PT - Jun 28th, 2026

@robobun, your commit 87174b5 has 1 failures in Build #66405 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32055

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

bun-32055 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Segfault in JSFFIFunction::trampoline on Windows standalone executable after sustained FFI polling #31941 - Segfault after sustained FFI polling with ptr(Uint32Array(1)) in a setInterval — the small typed array's backing store gets relocated by DFG tier-up, causing the pointer from ptr() to dangle, which is exactly the bug this PR fixes by forcing possiblySharedBuffer() before reading the address.

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

Fixes #31941

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0357ec8b-5886-41b2-8911-1b984ec2f7b4

📥 Commits

Reviewing files that changed from the base of the PR and between 2ad4199 and 6bbdade.

📒 Files selected for processing (4)
  • src/jsc/JSValue.rs
  • src/jsc/bindings/bindings.cpp
  • src/runtime/ffi/FFIObject.rs
  • test/js/bun/ffi/ffi.test.js

Walkthrough

Prevents dangling FFI pointers from typed arrays by stabilizing their backing storage before pointer capture. Adds a JSC FFI binding to force FastTypedArray into permanent, immovable memory, integrates it into FFIObject::ptr_(), and validates the fix with a regression test that forces JIT tier-up.

Changes

FFI Typed Array Pointer Stability

Layer / File(s) Summary
JSC typed array stabilization binding
src/jsc/bindings/bindings.cpp, src/jsc/JSValue.rs
New JSC__JSValue__ensureStableTypedArrayVector binding checks whether a value is a FastTypedArray; if so, calls possiblySharedBuffer() to force permanent stable backing storage and returns success status. Rust wrapper JSValue::ensure_stable_typed_array_vector() delegates to this FFI function.
FFIObject pointer capture guard
src/runtime/ffi/FFIObject.rs
The ptr_() function now calls ensure_stable_typed_array_vector() before processing typed arrays, returning an out-of-memory error if stabilization fails to prevent exposing dangling pointers beyond the call.
Regression test for pointer stability across tier-up
test/js/bun/ffi/ffi.test.js
New test spawns a child Bun process with JSC tier-up forced, captures a ptr() to a Float64Array, stresses it in a loop to trigger tier-up, and validates the captured pointer still reflects subsequent writes via read.f64().

Possibly related issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: ensuring ptr(typedArray) remains valid across DFG tier-up, which directly addresses the core issue.
Description check ✅ Passed The description fully addresses both required sections: it explains what the PR does (fixes stale typed-array pointers across DFG tier-up) and how it was verified (new regression test and original C repro both pass).
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.

✏️ 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.cpp

In file included from src/jsc/bindings/bindings.cpp:10:
src/jsc/bindings/root.h:42:10: fatal error: 'cmakeconfig.h' file not found
42 | #include "cmakeconfig.h"
| ^~~~~~~~~~~~~~~
1 error generated.
Aborting translation of method 'WebCore__FetchHeaders__append' in file 'src/jsc/bindings/bindings.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, characters 12-69
Called from Base__List.rev_filter_map.loop in file "src/list.ml", line 944, characters 13-17
Called from Base__List.filter_map in file "src/list.ml" (inlined), line

... [truncated 2200 characters] ...

-52
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.function_decl in file "src/clang/cFrontend_decl.ml", line 90, characters 12-151
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.translate_one_declaration in file "src/clang/cFrontend_decl.ml", line 453, characters 10-56
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.translate_one_declaration.translate in file "src/clang/cFrontend_decl.ml" (inlined), line 448, characters 24-96
Called from Stdlib__List.iter in file "list.ml", line 110, characters 12-15
Called from Stdlib__List.iter in file "list.ml", line 110, characters 12-15
Called from Stdlib__List.iter in file "list.ml" (inlined), line 110, characters 17-25
Called from Base__List0.iter in file "src/list0.ml" (inlined), line 25, characters 16


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

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, win32InstallCtrlCGuard) calls ptr(buf) fresh inside every poll tick and the address is consumed synchronously by the same GetConsoleMode call: there is no JS execution point between reading the vector address and the native write where a tier-up-driven relocation can run (plan finalization happens at JS function prologues and loop back-edges, and both callees are host functions). The bug this PR fixes requires a pointer captured once that outlives a later relocation; per-call ptr() re-reads the current vector and is unaffected either way.

The crash signature in #31941 (frame inside an unsymbolized module above JSFFIFunction::trampoline, Windows standalone build) looks like the #28135 / #28113 trampoline family instead, which the issue itself notes.

One honest caveat: with this PR, the first ptr(buf) call pins the storage permanently, so if #31941 were caused by any vector movement at all, it would be incidentally fixed. But that is not established, so I am not adding an auto-close line for a crash report that may have a different root cause. Worth retesting that workload on a build with this fix once it lands.

…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
@robobun
robobun force-pushed the farm/cdacbf14/ffi-ptr-stable-vector branch from 6bbdade to 1571a72 Compare June 28, 2026 10:58

@claude claude 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.

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__putFFI__ptr__slowpathWrapperptr_() (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).

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

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. ptr is currently registered with NoIntrinsic and no DOMJIT signature (the fastpath JSFunction::create with DOMJIT_ptr_signature is commented out in ZigGeneratedCode.cpp), so the only live path is FFI__ptr__put -> FFI__ptr__slowpathWrapper -> FFI__ptr__slowpath -> ptr_(), which is exactly where the guard sits. All current callers are covered. If that fastpath is ever revived, it would reintroduce this bug unless it forces the same fast-to-wasteful transition before reading view->vector(): a JIT-inlined read of the vector is precisely the "capture the address, then the engine relocates it" shape. Note the commented-out signature already used Effect::forReadWrite(top, top), so it was not claiming purity; the hazard is the raw vector read, not the effect annotation. I did not add a warning at the commented-out block because it is generated, dead code, but flagging it here for whoever reenables it.

Storage-mode assumptions to confirm:

  • Only FastTypedArray has GC-movable storage (vector in the GC heap). Every other mode has a stable data pointer: OversizeTypedArray is fastMalloc'd and adopted in place by slowDownAndWasteMemory() (no copy, no move), WastefulTypedArray/DataView already own an ArrayBuffer, and a plain JSArrayBuffer's data never moves. The guard early-returns for all of these, so it is a no-op except for the one mode that needs it.
  • The transition is one-time and monotonic: after the first ptr() the view is permanently wasteful, so later ptr() calls and indexed JS reads all see the same stable vector. This matches the repro where re-reading ptr(out) after tier-up returned a different address on the unfixed build and a stable one with the fix.
  • This is the same FastTypedArray classification and possiblySharedBuffer() materialization the adjacent borrowBytesForOffThread/pinArrayBuffer helpers already rely on in bindings.cpp, so the reasoning is not new to this PR.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

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 FFI.ptr (reachable solely via bun:ffi), a Rust wrapper, and a C++ helper that is called from nowhere else. The new regression test (test/js/bun/ffi/ffi.test.js) passes on every shard it ran, and no FFI test appears in any failure annotation across the recent builds. Verified locally: the test fails on the unfixed build and passes with the fix, and the full ffi.test.js suite is green.

Failures seen on the CI runs for this branch, none of which execute the changed code path:

  • Build 66405: test/js/bun/util/v8-heap-snapshot.test.ts killed by SIGKILL (OOM on a memory-heavy heap-snapshot test; no core file, no bun:ffi usage in the test). The rest were retried-to-green flakes, tagged context: flaky by the runner: bun-install, transpiler-cache, spawn (timeout), napi, s3, hot, watch-many-dirs.
  • Build 66369: two darwin aarch64 jobs failed with buildkite-agent artifact download timed out after 120s (artifact-store infra, no tests ran); the rest were context: flaky install/napi tests that passed on retry.

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.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Leaving this open while closing the other pre-#35246 bun:ffi PRs: #32054 still reproduces after #35246.

On 1.4.0-canary.1+da3851e57 (Linux x64, engine-native FFI), the issue's original repro (write_and_check through a captured ptr(out) in a hot loop) fails at default JIT thresholds in 3 of 3 runs, for example:

iter=47572: C wrote 572.5; memory actually contains 572.5; JS reads out[0] = 571.5; ptr(out) now = 5333429653840 (captured 5333451374896)
FAIL: 52376/100000 stale typed-array reads (first at iter 47572)

The ptr(out) address changing mid-run is the relocation this PR describes: ptr() on main still returns view->vector() of a FastTypedArray without forcing it out of fast mode (ptr_ in src/runtime/ffi/FFIObject.rs reading as_array_buffer().ptr), and DFG tier-up still relocates that storage. #35246 changed how the call itself is compiled (CallFFI clobbers the heap, so this is not load elimination), which does not affect a pointer captured earlier. Passing the typed array itself as the argument is unaffected because the engine reads the vector fresh on every call; a pointer captured once via ptr() is the case that breaks.

The test in this PR reproduces it without a native library (ptr(out) differs before and after the loop) on the same build. The branch needs a rebase onto the post-#35246 tree before it can go in.

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.

Typed-array reads after a bun:ffi call return stale values once the calling function is JIT-compiled (DFG load elimination across native calls)

1 participant