Add DFG/FTL nodes for byte-offset scalar accessors on ArrayBufferView receivers - #330
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughChangesBuffer accessor host functions, metadata registration, DFG and FTL compilation support, and stress tests were added for typed integer, floating-point, and BigInt Buffer reads and writes. Coverage includes bounds, coercion, exits, detached buffers, and resizable or growable backing stores. ChangesBuffer accessor JIT support
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@JSTests/stress/buffer-accessor-jit-resizable.js`:
- Around line 60-66: Replace the self-comparison in the shrink loop’s “read near
the shrunk end” assertion with an explicit expected value of 0, while preserving
the existing offset 6 read and RangeError checks for offsets 7 and 62.
In `@Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp`:
- Around line 5123-5124: Update the isResizable assignment in the affected
DataView accessor to use the exit-site check first, then fall back to
getArrayMode(Array::Read).mayBeResizableOrGrowableSharedTypedArray() when no
UnexpectedResizableArrayBufferView exit exists, matching the sibling DataView
get/set cases.
In `@Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp`:
- Around line 1989-1995: Guard the BufferReadInt, BufferReadFloat, and
BufferWrite switch cases with `#if` USE(BUN_JSC_ADDITIONS), and similarly guard
the compileBufferRead() and compileBufferWrite() method definitions plus
Node::bufferAccessData() with the same conditional. Keep these Bun-only dispatch
and implementation paths unchanged when the feature is enabled while excluding
them from non-Bun builds.
🪄 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: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 861241cc-1c43-470f-a89a-4e2723f17809
📒 Files selected for processing (27)
JSTests/stress/buffer-accessor-jit-bigint-write.jsJSTests/stress/buffer-accessor-jit-exits.jsJSTests/stress/buffer-accessor-jit-resizable.jsJSTests/stress/buffer-accessor-jit.jsSource/JavaScriptCore/CMakeLists.txtSource/JavaScriptCore/Sources.txtSource/JavaScriptCore/dfg/DFGAbstractInterpreterInlines.hSource/JavaScriptCore/dfg/DFGByteCodeParser.cppSource/JavaScriptCore/dfg/DFGClobberize.hSource/JavaScriptCore/dfg/DFGCloneHelper.hSource/JavaScriptCore/dfg/DFGDataViewData.hSource/JavaScriptCore/dfg/DFGDoesGC.cppSource/JavaScriptCore/dfg/DFGFixupPhase.cppSource/JavaScriptCore/dfg/DFGNode.hSource/JavaScriptCore/dfg/DFGNodeType.hSource/JavaScriptCore/dfg/DFGPredictionPropagationPhase.cppSource/JavaScriptCore/dfg/DFGSSALoweringPhase.cppSource/JavaScriptCore/dfg/DFGSafeToExecute.hSource/JavaScriptCore/dfg/DFGSpeculativeJIT.hSource/JavaScriptCore/dfg/DFGSpeculativeJIT32_64.cppSource/JavaScriptCore/dfg/DFGSpeculativeJIT64.cppSource/JavaScriptCore/ftl/FTLCapabilities.cppSource/JavaScriptCore/ftl/FTLLowerDFGToB3.cppSource/JavaScriptCore/runtime/BufferAccessorRegistry.cppSource/JavaScriptCore/runtime/BufferAccessorRegistry.hSource/JavaScriptCore/runtime/Intrinsic.hSource/JavaScriptCore/tools/JSDollarVM.cpp
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Source/JavaScriptCore/dfg/DFGFixupPhase.cpp`:
- Around line 3753-3775: Move fixEdge<KnownCellUse>(base) and
fixEdge<Int32Use>(offset) out of the non-forceExit branch and execute them after
the forceExit conditional, while preserving the existing ForceExit setup and
normal Uint8Array handling. Ensure both BufferReadInt and BufferWrite edges are
fixed on every path, including when blessArrayOperation() inserts ForceOSRExit.
🪄 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: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3f90886d-e19c-4b6e-be98-7f27706666ec
📒 Files selected for processing (1)
Source/JavaScriptCore/dfg/DFGFixupPhase.cpp
There was a problem hiding this comment.
I didn't find any bugs, but this adds three new DFG/FTL node types with ~600 lines of hand-written JIT codegen across both backends — bounds checking, speculation, and OSR-exit paths in a JIT compiler need expert human review.
What was reviewed:
- Bounds-check shape in both tiers (DFG inline: negative +
offset+byteSize-1 < length; FTL via twoCheckInBoundschildren in SSA lowering) — both cover the last byte. - BigInt 64-bit range check (length ≤ 1 digit + sign-match on wrapped value) matches the host reference in
JSDollarVM. - Clobberize/DoesGC/PredictionPropagation/AbstractInterpreter/SafeToExecute entries are consistent with the DataView-node precedents.
- ForceExit path leaves base/offset untyped (matches
GetByVal); AI marks state invalid so the node isn't compiled.
Extended reasoning...
Overview
This PR introduces three new DFG node types (BufferReadInt, BufferReadFloat, BufferWrite) plus a BufferAccessorIntrinsic and a process-global BufferAccessorRegistry, wired through the full DFG/FTL pipeline: bytecode parser, fixup, prediction propagation, abstract interpreter, clobberize, DoesGC, SSA lowering, safe-to-execute, clone helper, and both the DFG SpeculativeJIT64 and FTL LowerDFGToB3 backends. It also adds a ~260-line $vm.createBufferAccessors() test hook and four stress tests. 27 files changed.
Security risks
JIT compilers are among the most security-sensitive code in the engine. This change performs raw loads/stores through a caged storage pointer at a JS-controlled byte offset, guarded by speculation checks. Any gap between the speculation (CheckArray, Int32Use offset, CheckInBounds) and the actual load/store — or any way for the node to be hoisted/CSE'd past its guards — is an out-of-bounds read/write primitive. The design mirrors existing DataViewGet/Set and typed-array GetByVal/PutByVal closely, and the FTL path appends the CheckInBounds nodes as untyped children so LICM cannot separate them, which is the standard pattern. But correctness here depends on subtle interactions across many phases (e.g. SafeToExecute returning true for reads relies on edgesDominate gating LICM; the resizable-length path emits ExitOK after GetArrayLength; the ForceExit path relies on AI invalidating state so backends never compile the node). These need eyes from someone who owns this pipeline.
Level of scrutiny
High. New node types in the DFG/FTL are structural changes with correctness obligations spread across ~15 phase files, and the codegen does direct memory access. This is the opposite of a mechanical change.
Other factors
The PR is well-tested (four stress tests covering happy path, exits, resizable/growable-shared, BigInt writes) and well-documented. All CodeRabbit findings were either addressed in 4fa7b25 or withdrawn as incorrect after author explanation, and the bug-hunting system found nothing. That raises confidence but does not substitute for a human JSC reviewer on a change of this scope.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Source/JavaScriptCore/tools/JSDollarVM.cpp (1)
4773-4777: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winHandle NaN before the
int64_tcast.
numberValue < min || numberValue > maxdoes not reject NaN, sostatic_cast<int64_t>(std::trunc(numberValue))can hit undefined behavior. Convert NaN to0first here, matching the fixed-width accessor path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Source/JavaScriptCore/tools/JSDollarVM.cpp` around lines 4773 - 4777, Update the buffer accessor conversion near the range check to normalize NaN numberValue to 0 before calling std::trunc and casting to int64_t. Preserve the existing range validation and byte-writing behavior for non-NaN values, matching the fixed-width accessor path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@Source/JavaScriptCore/tools/JSDollarVM.cpp`:
- Around line 4773-4777: Update the buffer accessor conversion near the range
check to normalize NaN numberValue to 0 before calling std::trunc and casting to
int64_t. Preserve the existing range validation and byte-writing behavior for
non-NaN values, matching the fixed-width accessor path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 988fd86c-0b1a-4638-b62a-111332797734
📒 Files selected for processing (1)
Source/JavaScriptCore/tools/JSDollarVM.cpp
Preview Builds
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
Source/JavaScriptCore/tools/JSDollarVM.cpp (2)
4773-4780: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winHandle
NaNbeforestatic_cast<int64_t>in the variable-width write path.
NaNbypasses the range check, thenstd::trunc(numberValue)still leavesNaN, so the integer cast is undefined behavior. The fixed-width path already normalizesNaNto0; this path should do the same or reject it explicitly.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Source/JavaScriptCore/tools/JSDollarVM.cpp` around lines 4773 - 4780, Update the variable-width write path guarded by isWrite before the static_cast<int64_t> conversion to explicitly handle NaN, matching the fixed-width path by normalizing it to 0 or rejecting it with the appropriate range error. Ensure the subsequent truncation and integer cast only receive a non-NaN value.
4587-4597: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winClamp buffer accessor offsets to int32. The DFG path treats this offset as
Int32, so the host fallback can accept larger integral values on large views and diverge from inlined call sites. Add theINT32_MAXcap to keep both paths aligned.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Source/JavaScriptCore/tools/JSDollarVM.cpp` around lines 4587 - 4597, Update the buffer accessor offset validation in the shown offset handling path to reject integral offsetNumber values greater than INT32_MAX, alongside the existing floor, nonnegative, and byteLength bounds checks. Keep valid in-range Int32 offsets unchanged so the host fallback matches the DFG path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@Source/JavaScriptCore/tools/JSDollarVM.cpp`:
- Around line 4773-4780: Update the variable-width write path guarded by isWrite
before the static_cast<int64_t> conversion to explicitly handle NaN, matching
the fixed-width path by normalizing it to 0 or rejecting it with the appropriate
range error. Ensure the subsequent truncation and integer cast only receive a
non-NaN value.
- Around line 4587-4597: Update the buffer accessor offset validation in the
shown offset handling path to reject integral offsetNumber values greater than
INT32_MAX, alongside the existing floor, nonnegative, and byteLength bounds
checks. Keep valid in-range Int32 offsets unchanged so the host fallback matches
the DFG path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3063ce5d-8c5c-496d-880a-f467aafc2a81
📒 Files selected for processing (1)
Source/JavaScriptCore/tools/JSDollarVM.cpp
There was a problem hiding this comment.
No new issues found this pass, but this adds ~600 lines of hand-written DFG/FTL codegen for bounds-checked typed-array memory access across every compiler phase — JIT-security-sensitive enough that it needs eyes from someone familiar with the DFG pipeline.
What was reviewed:
- DFG64 and FTL
compileBufferRead/Writebounds sequences — both do theoffset >= 0+offset + byteSize - 1 < lengthpair, matching the DataView pattern. - SSA lowering's
CheckInBoundspair and value-rangeCheckInBoundsfor narrow-int writes — offsets/ranges look consistent with the inline DFG checks. - The two earlier
JSDollarVM.cppfindings (impure-NaN float reads, NaN falling through the var-width range guard) — both fixed in a4ecdd6 and now covered by tests. clobberize/doesGC/safeToExecute/PredictionPropagationentries — consistent with the sibling DataView node handling.
Extended reasoning...
Overview
This PR adds three new DFG/FTL node types — BufferReadInt, BufferReadFloat, BufferWrite — that let the JIT inline Node.js Buffer.prototype.read*/write* host functions as direct typed-array loads/stores. It touches 28 files: a new process-global BufferAccessorRegistry, a new BufferAccessorIntrinsic, bytecode-parser hookup, and per-phase handling in Fixup, PredictionPropagation, AbstractInterpreter, Clobberize, DoesGC, SafeToExecute, SSALowering, CloneHelper, plus ~300 lines of SpeculativeJIT64 codegen and ~220 lines of FTL B3 lowering. A ~330-line $vm.createBufferAccessors() reference implementation and five stress tests round it out.
Security risks
This is JIT compiler code that emits machine instructions doing bounds-checked reads/writes into ArrayBufferView backing storage. A soundness bug in the bounds check (off-by-one, wrong length source, missing resizable-view refresh, speculation that isn't backed by a check) is an out-of-bounds read/write primitive. The two-sided bounds check, resizable-view handling (loadTypedArrayLength vs. cached length + UnexpectedResizableArrayBufferView speculation), BigInt digit-count/sign checks, and ensureStillAliveHere(base) liveness fence all look correctly patterned after the existing DataView/typed-array paths, but this class of code historically produces CVEs and deserves an expert second pair of eyes rather than a bot sign-off.
Level of scrutiny
High. This is not a mechanical or pattern-following change — it's a new node family with bespoke codegen in two backends, custom SSA-lowering that appends multiple CheckInBounds children, and OSR-exit-based error semantics whose correctness depends on the interaction between speculation checks and the host-function fallback. Even with the feature guarded by USE(BUN_JSC_ADDITIONS), it will ship in production Bun and back the Buffer prototype.
Other factors
All prior review threads (CodeRabbit's four, my two) are resolved, and the author's responses were substantive. Test coverage is good — five stress files exercise every width/endianness/sign combination, resizable/growable-shared views, detach, OSR exits, BigInt range, and the impure-NaN case. The multi-agent bug hunt found nothing new on the current revision. None of that changes the calculus: the size and security surface put this well outside what should be approved without human review.
There was a problem hiding this comment.
No new issues found on this pass — the Overflow exit-site gate is now in place (8debd97). That said, this adds three new DFG/FTL node types with hand-written bounds-check lowering across SpeculativeJIT64, FTLLowerDFGToB3 and SSALowering, so a human pass on the JIT paths is still warranted.
What was reviewed:
- Bounds-check shape in both tiers: negative-offset guard + zero-extended
offset + byteSize - 1vs. 64-bit length; FTL's separateCheckInBoundson first/last byte with Int52 length underLARGE_TYPED_ARRAYS. - Value-range checks (int8/16 min-max, uint32 Int52 upper bound, BigInt length/sign) and that all exit kinds now have a bytecode-parser back-off.
ensureStillAliveHere(base)after the FTL loads/stores; Fixup's ForceExit path leaving base/offset untyped while still setting the result flags.$vmreference-implementation NaN handling (purifyNaN on float reads, negated range guard on var-width writes) — both prior findings confirmed fixed.
Extended reasoning...
Overview
This PR introduces three new DFG node types — BufferReadInt, BufferReadFloat, BufferWrite — plus a BufferAccessorIntrinsic and a process-global BufferAccessorRegistry so Bun can JIT-compile Node.js-style Buffer.prototype.read*/write* host functions. It touches ~31 files across the full DFG/FTL pipeline: bytecode parsing, prediction propagation, fixup, abstract interpreter, clobberize, doesGC, safeToExecute, clone helper, SSA lowering, both DFG SpeculativeJIT backends, FTL capabilities, and FTL lowering. Roughly ~300 lines of new SpeculativeJIT64 codegen and ~220 lines of new B3 lowering, plus ~330 lines of $vm reference implementation and eight new stress tests.
Security risks
JIT bounds-check emission on typed-array storage is squarely memory-safety-critical. The checks look sound (negative-offset guard, zero-extend before the 64-bit add, unsigned compare against the 64-bit length; FTL relies on graph-level CheckInBounds/CheckInBoundsInt52 inserted at SSA lowering with the check nodes threaded as vararg children so B3 can't reorder the store above them), and ensureStillAliveHere(base) keeps the receiver live across the load/store. Gigacage caging comes from the shared GetIndexedPropertyStorage path. Resizable/growable-shared handling is delegated to the existing loadTypedArrayLength / GetTypedArrayLengthAsInt52 machinery. I did not find a hole, but a mistake here would be an out-of-bounds read/write from JIT'd code, which is why this shouldn't be auto-approved.
Level of scrutiny
High. New node types must be correctly wired into every DFG phase (missing a case in one switch is a classic source of miscompiles), and the two backend implementations independently reimplement bounds/range checks that must agree with each other and with the host function they OSR-exit to. This is Bun-gated (USE(BUN_JSC_ADDITIONS)) so upstream WebKit is unaffected, but it's production Bun code once merged.
Other factors
All prior automated findings on this PR (impure-NaN boxing in the $vm float reads, NaN fall-through in the var-width writer, missing Overflow exit-site back-off) have been addressed by the author in follow-up commits, and CodeRabbit's threads are resolved. The stress-test coverage is unusually thorough (differential fuzzer, resizable/detached, >2GB and ~4GB byteOffset views, BigInt range, exit-site behavior). The bug-hunting system found nothing on this revision. Deferring rather than approving purely on scope and blast radius.
… receivers Introduces the BufferReadInt / BufferReadFloat / BufferWrite nodes (all under USE(BUN_JSC_ADDITIONS)) plus a small BufferAccessorRegistry: an embedder registers host functions that read or write a fixed-width scalar at a byte offset on an ArrayBufferView receiver (Node.js's Buffer.prototype.readInt32LE, writeDoubleBE, ...) and the DFG turns call sites of those functions into bounds-checked loads and stores on the receiver's storage. The nodes are shaped like typed-array GetByVal / PutByVal: Fixup forces a Uint8Array ArrayMode and blesses the array operation (CheckArray + GetIndexedPropertyStorage), and SSA lowering appends GetArrayLength plus a CheckInBounds for the first and last byte, so the receiver check, the storage pointer, the length load and the bounds checks are all ordinary CSE-able / hoistable / IntegerRange-eliminable nodes. The DFG tier does the length load and bounds check inline like DataViewGet/Set. Everything the nodes do not speculate (other receivers, non-int32 or out-of-bounds offsets, out-of-range write values, oversized BigInts) OSR-exits back to the host function, which stays the single owner of the error semantics. DataViewData moves into dfg/DFGDataViewData.h so the registry header can carry the access descriptor without pulling in DFGNode.h. Tests: JSTests/stress/buffer-accessor-jit*.js via a $vm.createBufferAccessors() hook in JSDollarVM.
…ngth argument is a constant 1, 2 or 4
…L_ATTRIBUTES (Windows)
Move the 1- and 2-byte write value range checks out of the backends and into SSA lowering as a (sign-biased) CheckInBounds, so integer range analysis / LICM can hoist or eliminate them; the DFG tier keeps its inline checks since SSA lowering only runs in the FTL pipeline. BufferWrite no longer has a result. The parser emits Node's `offset + byteSize` return value as an ordinary ArithAdd before the store, which DCE removes when the caller ignores it and which stays a checked int32 add when it is used (loop-carried offsets included).
The reference reader boxed raw loaded bytes, which asserts in jsNumber() for an impure NaN bit pattern (and encodes a NaN-boxing-colliding JSValue in release). The variable-width writer let NaN past its range guard and into an out-of-range float-to-int conversion. Both paths are now covered by the stress tests.
Under USE(LARGE_TYPED_ARRAYS) the receiver can exceed 2GB, so lower the length load and bounds checks to GetTypedArrayLengthAsInt52 / CheckInBoundsInt52 the way lowerBoundsCheck() does for typed array element accesses. The Int32 GetArrayLength speculation Overflow-exited on every FTL execution for such receivers, and with Overflow in the parser's exit-site gate the call site then stopped inlining for good. Overflow is dropped from that gate as well, and a stress test covers a 3GB receiver end to end.
Add a seeded JIT-vs-reference differential fuzzer: per-accessor generated invokers (the reference arm's source must differ from the JIT arm's or noDFG on the shared executable poisons both), clean-input rounds that keep the site optimized and compare bytes/results on the fast path, and dirty-input rounds for error parity, over fixed, subarray, resizable and growable receivers. Put Overflow back into the parser's exit-site gate. Removing it let the remaining Overflow exits (double offsets >= 2^31, the lastByteOffset int32 add near 2^31, DFG-tier value checks) exit and recompile indefinitely; the Int52 length path already keeps large receivers optimized on its own since the length load can no longer produce an Overflow exit.
…allocation, drop whitespace churn
… as possibly-impure NaN
8debd97 to
20fe99f
Compare
…ransferArrayBuffer in the differential fuzzer
There was a problem hiding this comment.
I re-reviewed after 7e0f4d6 and didn't find any new issues — thanks for switching the differential fuzzer to transferArrayBuffer and moving the stress tests onto testLoopCount. Given the scope (three new DFG/FTL node types with hand-written SpeculativeJIT and B3 lowering, new SSA-lowered bounds checks on typed-array storage, and speculation/exit-kind plumbing across ~20 compiler files), a human pass over the codegen and clobberize/safeToExecute modelling would still be worthwhile.
What was reviewed
- Bounds-check shape in both tiers: DFG's inline
branch32(LessThan, offset, 0)+ zero-extendedoffset + byteSize - 1 < lengthand FTL's loweredCheckInBounds/CheckInBoundsInt52pair — both check first and last byte, and the length load respects resizable/growable views. ensureStillAliveHere(base)is present after the FTL load/store so the view can't be collected while its storage pointer is live.- The
Array::ForceExitpath in fixup leaves base/offset untyped and clobberize/AI treat it as terminal — no uncheckedKnownCellUseon that branch. - BigInt write range check:
length > 1→ exit, then sign-vs-top-bit mismatch guard; matches the host reference for ±2⁶³ boundaries.
Extended reasoning...
Overview
This PR adds three new DFG/FTL node types — BufferReadInt, BufferReadFloat, BufferWrite — that inline Node.js-style Buffer.prototype.read*/write* accessors on ArrayBufferView receivers. It threads them through the full DFG pipeline (DFGByteCodeParser, DFGFixupPhase, DFGPredictionPropagationPhase, DFGAbstractInterpreterInlines, DFGClobberize, DFGDoesGC, DFGSafeToExecute, DFGSSALoweringPhase, DFGSpeculativeJIT64, FTLLowerDFGToB3, FTLCapabilities, DFGCloneHelper), adds a process-global BufferAccessorRegistry keyed by native-function pointer, moves DataViewData to its own header, adds a BufferAccessorIntrinsic enumerator, and ships a $vm.createBufferAccessors() reference implementation plus eleven stress tests. Roughly 1.5k lines of new code across 33 files, essentially all of it inside the JIT.
Security risks
The core of this change is emitting raw loads/stores against caged typed-array storage with speculated bounds checks. The hazards are the usual JIT ones: an off-by-one in the offset + byteSize - 1 < length check, a path where the storage edge is consumed without the preceding CheckArray/GetIndexedPropertyStorage having proven the receiver is a live Uint8Array, a safeToExecute entry that lets LICM hoist a read above its array check, or a clobberize model that lets CSE reuse a stale length across a resize. I traced each of those and they look correctly modelled (reads def a TypedArrayProperties heap location keyed on the descriptor quadword; the resizable path pessimises to write(MiscFields|TypedArrayProperties); safeToExecute gates on arrayMode().alreadyChecked; FTL keeps the base alive past the pointer use). But this is exactly the class of code where a subtle interaction with an existing phase (integer-range analysis eliminating the second CheckInBounds, watchpoint-based length folding, etc.) can turn into an OOB write, so it deserves a human reviewer who knows the DFG invariants.
Level of scrutiny
High. This is not a config tweak or a mechanical refactor — it is new speculative-JIT codegen for memory accesses, gated behind USE(BUN_JSC_ADDITIONS) but shipped to every Bun user. The node definitions themselves are unconditional (only the parser hookup is guarded), so they also need to stay structurally consistent with the rest of the DFG for upstream-merge purposes.
Other factors
The author has been responsive: earlier automated findings (impure-NaN boxing in the $vm reference, NaN falling through the var-width range guard, the missing Overflow/Int52Overflow/Uncountable exit-site checks, structuredClone in the fuzzer, hardcoded loop counts) were each fixed with a targeted commit and covered by a new test. The stress suite is broad — differential fuzzing against a noDFG reference, resizable/growable/detached receivers, >2 GB offsets, impure-NaN patterns, BigInt range edges, recompile-count assertions — and the preview builds are green. None of that substitutes for a human read of ~300 lines of hand-written SpeculativeJIT assembly and B3 patchpoints, which is why I'm deferring rather than approving.
…site has seen a >INT32_MAX view Mirror lowerBoundsCheck(): GetArrayLength (which re-checks length <= INT32_MAX on every execution and exits Overflow) + CheckInBounds, which integer range analysis can hoist and eliminate; fall back to GetTypedArrayLengthAsInt52 + CheckInBoundsInt52 once the array profile or an Overflow exit says the site sees larger views. Overflow therefore stops being a reason not to inline; the exits that mean "the host call would throw" (value range, uint32 and BigInt range) become OutOfBounds so they still are, DoubleAsInt32 is not re-inserted and the site's int32 adds stop checking after an Overflow exit so nothing at the site can loop on it. Adds buffer-accessor-jit-large-after-small.js: sites compiled on a 64-byte view, then fed a 3GB view and a resizable view grown past INT32_MAX in place.
…2 bounds checks in the FTL)
There was a problem hiding this comment.
I reviewed the latest revision (through cde40c1) and found no new issues — the earlier findings on testLoopCount, structuredClone/transferArrayBuffer, the Overflow exit-site guard, and the purifyNaN/NaN-range $vm helpers are all addressed. Given the scope — three new DFG/FTL node types with hand-written bounds-check lowering and JIT-emitted memory access across the ByteCodeParser/Fixup/SSALowering/SpeculativeJIT/FTLLowerDFGToB3 pipeline — a human look is still warranted.
What was reviewed:
- Bounds-check lowering in
DFGSSALoweringPhase(first-byte + last-byteCheckInBounds, int32 vs int52 length selection,Arith::Uncheckedadd after an Overflow exit) — the uncheckedoffset + byteSize - 1wrap is caught by the unsigned first-byte check. - DFG-tier
compileBufferRead/compileBufferWrite: length load, negative-offset guard, zero-extended offset +byteSize-1vs 64-bit length, resizable/detached handling, BigInt sign/length range checks. - FTL
compileBufferRead/compileBufferWrite: relies on the SSA-loweredCheckInBoundschildren;ensureStillAliveHere(base)keeps the view alive across the load/store. BufferAccessorRegistrydescriptor validation and thebyteLengthFromArgumentconstant-width path inByteCodeParser.
Extended reasoning...
Overview
This PR introduces three new DFG/FTL node types (BufferReadInt, BufferReadFloat, BufferWrite) that inline Node.js-style Buffer.prototype.readInt32LE / writeDoubleBE / etc. host functions on ArrayBufferView receivers. It threads them through the full DFG pipeline: DFGByteCodeParser (intrinsic recognition via a new process-global BufferAccessorRegistry), DFGFixupPhase (forced Uint8Array array mode + blessArrayOperation, edge use-kinds), DFGSSALoweringPhase (separate GetArrayLength/GetTypedArrayLengthAsInt52 + two CheckInBounds nodes + a value-range CheckInBounds for narrow-int writes), DFGSpeculativeJIT64 (~320 lines of hand-written codegen for the DFG tier), and FTLLowerDFGToB3 (~220 lines for the FTL tier). Supporting boilerplate touches DFGAbstractInterpreterInlines, DFGClobberize, DFGDoesGC, DFGSafeToExecute, DFGPredictionPropagationPhase, DFGCloneHelper, DFGNodeType, DFGNode, FTLCapabilities, and Intrinsic.h. DataViewData was extracted to its own header so the registry can use it. A ~330-line $vm.createBufferAccessors() reference implementation was added to JSDollarVM.cpp, plus twelve stress tests covering reads/writes of every width, endianness, resizable/growable/detached receivers, >2 GB views, impure-NaN patterns, fractional/NaN write values, BigInt range checks, the var-width forms, and a differential fuzzer.
Security risks
This is squarely security-sensitive: the new nodes emit JIT code that computes a raw pointer from storage + zeroExt(offset) and loads/stores 1–8 bytes there, guarded only by speculation checks the PR itself inserts. A mistake in the bounds-check emission (wrong length source, signed/unsigned confusion, an off-by-one on byteSize - 1, a missing check when CheckInBounds is eliminated by later phases, or a stale storage pointer after a resize/detach) would be an out-of-bounds read/write reachable from JavaScript — the classic JIT-engine memory-safety bug class. The PR mitigates this by reusing the same shape as typed-array GetByVal/PutByVal (blessArrayOperation → CheckArray + GetIndexedPropertyStorage; SSA lowering appends CheckInBounds children exactly like lowerBoundsCheck), by keeping base alive across the access in FTL, and by loading the live length (loadTypedArrayLength for resizable views) in the DFG tier. The exit-site backoff (BadType/BadIndexingType/OutOfBounds/Int52Overflow/Uncountable, plus Overflow handled via mayBeLargeTypedArray) is designed so anything the speculation can't cover falls back to the host function. I did not find a hole in that reasoning, but the surface area is large enough that I'm not confident an automated pass alone is sufficient.
Level of scrutiny
High. This is production JIT-compiler code in a memory-unsafe language, generating machine code that dereferences attacker-influenced offsets. It is neither simple, mechanical, nor self-contained — it interacts with array-mode refinement, LICM/CSE (via DFGSafeToExecute and DFGClobberize), integer-range analysis, and OSR-exit semantics. Even though it is gated on USE(BUN_JSC_ADDITIONS) and the intrinsic only fires for functions Bun explicitly registers, once registered it is on the hot path for every Buffer read/write in a Bun process.
Other factors
The PR has extensive test coverage (twelve stress tests including a differential fuzzer, large-view/resizable/detached/impure-NaN/BigInt edge cases) and has been through several rounds of automated review; every prior finding — purifyNaN in the float-read helper, the NaN UB in varWidthAccessor, the missing Overflow exit-site backoff, hardcoded loop counts vs testLoopCount, and the structuredClone dead branch in the fuzzer — has been fixed and the corresponding threads are resolved. The most recent commits (7e0f4d60, cde40c15) are exactly those fixes plus the int32/int52 length-selection refinement in SSA lowering. No open reviewer comments remain. Nonetheless, per the approval guidelines this is a large change touching critical/security-sensitive code paths, so I'm deferring rather than approving.
…35343) ## `Buffer.prototype.read*` / `write*` become JIT-inlined native functions All of the accessors (`readInt8` … `readDoubleBE`, the BigInt64 reads/writes, and the variable-width `readIntLE/BE`, `readUIntLE/BE`, `writeIntLE/BE`, `writeUIntLE/BE`) move from JS builtins that lazily created a hidden `DataView` on each Buffer to C++ host functions in `JSBuffer.cpp`. Each is registered with JSC's new `BufferAccessorRegistry` and carries `BufferAccessorIntrinsic`, so DFG/FTL compile call sites into a bounds-checked load/store on the receiver's storage; for the variable-width family, a call site whose `byteLength` argument is a constant 1, 2 or 4 gets the same node. Depends on oven-sh/WebKit#330 (`WEBKIT_VERSION` currently points at that PR's preview build). The host functions are the single source of truth for behavior: they mirror `lib/internal/buffer.js`'s `checkBounds()` / `checkInt()` / `boundsError()` (error codes, messages, and the per-function argument-validation order), and the JIT exits back to them for anything it doesn't speculate. The now-unused `$checkBufferRead` global, the `$dataView` private name, and `internal/buffer.js` are removed. Benchmark: CI build of this PR (`bunx bun-pr 35343`) vs Bun 1.4.0 vs Node 26.5.0, macOS arm64, ns per operation ([bench/snippets/buffer-read-write.mjs](bench/snippets/buffer-read-write.mjs)): | op | Node 26 | Bun 1.4.0 | this PR | vs 1.4.0 | vs Node | |---|---|---|---|---|---| | readInt8 | 0.56 | 0.44 | 0.29 | 1.5× | 1.9× | | readUInt8 | 0.29 | 0.48 | 0.29 | 1.6× | 1.0× | | readInt16BE | 0.52 | 0.67 | 0.43 | 1.6× | 1.2× | | readInt32LE | 0.90 | 0.92 | 0.64 | 1.4× | 1.4× | | readUInt32BE | 0.85 | 0.76 | 0.42 | 1.8× | 2.0× | | readFloatLE | 0.73 | 0.80 | 0.71 | 1.1× | 1.0× | | readDoubleLE | 1.25 | 0.79 | 0.70 | 1.1× | 1.8× | | readBigInt64LE | 26.6 | 11.8 | 11.1 | 1.1× | 2.4× | | writeUInt8 | 0.28 | 2.24 | 0.31 | 7.2× | 0.9× | | writeInt16BE | 0.31 | 2.46 | 0.40 | 6.2× | 0.8× | | writeInt32LE | 0.64 | 0.42 | 0.30 | 1.4× | 2.1× | | writeUInt32BE | 0.58 | 2.61 | 0.43 | 6.1× | 1.3× | | writeFloatLE | 1.53 | 2.85 | 0.31 | 9.2× | 4.9× | | writeDoubleLE | 2.43 | 2.91 | 0.34 | 8.5× | 7.1× | | readInt32LE — 1024 distinct buffers | 1.83 | 2.60 | 0.77 | 3.4× | 2.4× | | writeInt32LE — 1024 distinct buffers | 1.53 | 2.38 | 0.64 | 3.7× | 2.4× | | readDoubleLE — 1024 distinct buffers | 3.02 | 2.08 | 0.75 | 2.8× | 4.0× | Writes moved the most because the old builtins bounds-checked each store by loading `this[offset]` / `this[offset+1]` and comparing to `undefined`, then loaded the hidden `$dataView` field, then called `DataView.setX` (which re-checks bounds); now it is one range check + one bounds check + the store, all hoistable in loops (the 1- and 2-byte value range checks are graph nodes visible to integer range analysis, and the `offset + byteSize` return value is dead unless the caller uses it). Tests: `test/js/node/buffer.test.js` (tier-up coverage), `test/js/node/buffer-jit.test.ts` (compile counts converge, per-exit host fallback, load/store ordering and cross-view aliasing, prototype/instance replacement after tier-up, resizable/growable receivers), plus Node's `test-buffer-*` parallel suite, all on a debug/ASAN build. On the JSC side, the five `buffer-accessor-jit*.js` stress tests pass under 20 fuzzing / stress configurations (randomizing / double / narrowing / widening prediction agents at several seeds, OSR-exit fuzzing at every check, AI-state validation, eager compilation, and `collectContinuously` / `forceGCSlowPaths` GC stress). ### Linux x64 (2026-08-22) CI build of this PR (`bunx bun-pr 4618381`) vs the CI build of its merge-base on main (`85deae0`, the JS-builtin implementation) vs Node 26.3.0. 64-core x86_64 EC2 box, idle, pinned to 8 cores, 5 interleaved rounds per arm, median of the mitata averages. ns per accessor call ([bench/snippets/buffer-read-write.mjs](bench/snippets/buffer-read-write.mjs)): | op | Node 26 | main | this PR | vs main | vs Node | |---|---|---|---|---|---| | readInt8 | 0.55 | 0.55 | 0.41 | 1.3× | 1.3× | | readUInt8 | 0.31 | 0.54 | 0.27 | 2.0× | 1.1× | | readInt16BE | 0.82 | 0.83 | 0.55 | 1.5× | 1.5× | | readInt32LE | 1.40 | 1.11 | 0.98 | 1.1× | 1.4× | | readUInt32BE | 1.53 | 0.82 | 1.10 | 0.8× | 1.4× | | readFloatLE | 7.63 | 0.85 | 0.98 | 0.9× | 7.8× | | readDoubleLE | 7.43 | 1.10 | 0.83 | 1.3× | 8.9× | | readBigInt64LE | 40.5 | 13.9 | 13.7 | 1.0× | 3.0× | | writeUInt8 | 0.28 | 4.17 | 0.54 | 7.7× | 0.5× | | writeInt16BE | 0.50 | 3.81 | 0.55 | 7.0× | 0.9× | | writeInt32LE | 1.09 | 0.55 | 0.55 | 1.0× | 2.0× | | writeUInt32BE | 1.00 | 3.81 | 0.55 | 6.9× | 1.8× | | writeFloatLE | 1.48 | 4.15 | 0.55 | 7.5× | 2.7× | | writeDoubleLE | 3.04 | 4.11 | 0.56 | 7.3× | 5.4× | | readInt32LE(0) — constant offset | 1.30 | 0.33 | 0.39 | 0.8× | 3.3× | | writeInt32LE(v, 0) — constant offset | 0.79 | 0.27 | 0.26 | 1.0× | 3.0× | | readInt32LE — 1024 distinct buffers | 2.68 | 3.59 | 1.16 | 3.1× | 2.3× | | writeInt32LE — 1024 distinct buffers | 2.20 | 2.33 | 1.11 | 2.1× | 2.0× | | readDoubleLE — 1024 distinct buffers | 7.50 | 2.36 | 1.07 | 2.2× | 7.0× | Most cells land on a multiple of ~0.27 ns: at 4096 calls per sample the loop runs at one CPU cycle per iteration, so a 0.27 ns step is one cycle. `readUInt32BE` (0.82 → 1.10) and `readInt32LE(0)` (0.33 → 0.39) are one cycle slower on this box in every round. `readFloatLE` is within noise. Everything else is flat or faster; the writes and the many-buffers cases are where the old path paid for the hidden `DataView` + `undefined` bounds probes. Note the two arms differ in WebKit (`b7f217b4` on main vs the `preview-pr-330` build for this PR); that is unavoidable because the intrinsic lives in oven-sh/WebKit#330. `perf stat` instructions and cycles per call for the same accessors (earlier local `release-local` build, Xeon 8488C, process startup subtracted): | accessor | insn/call main | insn/call PR | cycles/call main | cycles/call PR | cycles speedup | |---|---|---|---|---|---| | readUInt8 | 21.3 | 14.4 | 9.0 | 4.9 | 1.83× | | readInt16BE | 28.3 | 18.7 | 10.1 | 5.9 | 1.72× | | readInt32LE | 33.4 | 24.2 | 10.5 | 8.3 | 1.26× | | readUInt32BE | 30.1 | 28.8 | 9.6 | 8.6 | 1.12× | | readFloatLE | 26.8 | 17.9 | 10.9 | 9.5 | 1.15× | | readDoubleLE | 24.7 | 16.3 | 9.9 | 5.7 | 1.73× | | readBigInt64LE | 367.6 | 357.5 | 150.8 | 167.8 | 0.90× | | writeUInt8 | 29.6 | 15.6 | 9.4 | 6.3 | 1.49× | | writeInt16BE | 54.5 | 20.7 | 19.1 | 7.0 | 2.72× | | writeInt32LE | 29.2 | 18.3 | 10.8 | 4.8 | 2.26× | | writeUInt32BE | 62.7 | 21.1 | 17.4 | 7.9 | 2.20× | | writeFloatLE | 56.8 | 21.1 | 20.9 | 6.0 | 3.48× | | writeDoubleLE | 54.4 | 19.5 | 17.6 | 5.5 | 3.20× | | writeBigInt64LE | 271.5 | 65.4 | 73.9 | 23.7 | 3.12× | `readBigInt64LE` is dominated by the BigInt arithmetic in the benchmark loop, so the accessor itself is within noise of the old path there. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Main moved WEBKIT_VERSION to aea1f010 (oven-sh/WebKit#330, the buffer accessor intrinsic that #35343 registers). oven-sh/WebKit#268 is rebased onto that commit, so its preview carries everything main's pin has plus the two async context fixes.
Buffer accessor nodes (
BufferReadInt/BufferReadFloat/BufferWrite)Adds a DFG/FTL intrinsic for host functions that read/write a fixed-width scalar on an
ArrayBufferView receiver at a byte offset — Node.js's
Buffer.prototype.readInt32LE,writeDoubleBE, etc. (Bun registers its native implementations of those with the newBufferAccessorRegistry.) Everything is underUSE(BUN_JSC_ADDITIONS).runtime/BufferAccessorRegistry.{h,cpp}{ DataViewData, isWrite }BufferAccessorIntrinsic(Intrinsic.h)dfg/DFGDataViewData.hDataViewDatamoved out ofDFGNode.hso the registry can use itBufferReadInt/BufferReadFloat/BufferWriteGetByVal/PutByVal: opInfo1 =ArrayMode(forcedUint8Array), opInfo2 =DataViewDataHow the nodes flow through the pipeline (each step reuses existing machinery):
read*(offset = 0)/write*(value, offset = 0)call sites on a registered function become the node; bails when the site has BadType/BadIndexingType/OutOfBounds/Overflow exit sites (so a persistently-exiting site keeps the plain call).ArrayMode(Uint8Array)and callsblessArrayOperation()→ the receiver check is aCheckArray, the storage is aGetIndexedPropertyStorage; offsetInt32Use(with the sameDoubleAsInt32conversionGetByValdoes); write values Int32 / Int52 (uint32) / Double / HeapBigInt.GetArrayLength+CheckInBounds(offset)+CheckInBounds(offset + byteSize - 1)appended as untyped children, same aslowerBoundsCheck()— so type check, storage, length and bounds are all separately CSE-able / hoistable / IntegerRange-eliminable.compileDataViewGet/Setsequence); FTL loads/stores through the already-checked pointer. Endianness is a per-accessor constant (byte swap, no branch). Writes also range-check the value the way Node requires (narrow ints, uint32, "fits in 64 bits" for BigInt) — those are OSR exits, not throws.Tests:
JSTests/stress/buffer-accessor-jit{,-exits,-resizable,-bigint-write}.jsvia a$vm.createBufferAccessors()hook; they pass in default, no-cjit, ftl-eager, no-ftl and validateGraph configurations. The existing DataView / Atomics / typed-array stress tests are unaffected.