Skip to content

Make Buffer read*/write* native functions with a DFG/FTL intrinsic - #35343

Open
Jarred-Sumner wants to merge 26 commits into
mainfrom
claude/buffer-jit
Open

Make Buffer read*/write* native functions with a DFG/FTL intrinsic#35343
Jarred-Sumner wants to merge 26 commits into
mainfrom
claude/buffer-jit

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Buffer.prototype.read* / write* become JIT-inlined native functions

All of the accessors (readInt8readDoubleBE, 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):

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

The fixed-width accessors (readInt8 ... readDoubleBE, the BigInt64 reads,
writeInt8 ... writeDoubleBE) were JS builtins that lazily created a hidden
DataView on each Buffer (`this.$dataView ||= new DataView(...)`) and validated
through $checkBufferRead / internal/buffer.js. They are now C++ host functions
in JSBuffer.cpp with the same semantics (error codes, messages and the
per-function argument-validation order of lib/internal/buffer.js), registered
with JSC's BufferAccessorRegistry and carrying BufferAccessorIntrinsic so the
DFG/FTL compile call sites into bounds-checked loads/stores on the receiver's
storage and OSR-exit back to the host function for anything they don't
speculate. The existing writeBigInt64* host functions get the intrinsic too.

The variable-width accessors (readIntLE etc.) are unchanged; the now-unused
$checkBufferRead global is removed.

Adds tier-up coverage to test/js/node/buffer.test.js and a
bench/snippets/buffer-read-write.mjs benchmark.
@Jarred-Sumner
Jarred-Sumner requested a review from alii as a code owner July 24, 2026 01:01
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator
Updated 2:59 AM PT - Jul 26th, 2026

@Jarred-Sumner, your commit 2a8c8377e3d4ef3abc06510f295146321554a26f passed in Build #82179! 🎉


🧪   To try this PR locally:

bunx bun-pr 35343

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

bun-35343 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Different node:buffer perf characteristic #23164 - Reports node:buffer performance being slower than Node.js; this PR's native C++ rewrite of all 36 fixed-width read*/write* methods with DFG/FTL JIT intrinsics directly addresses Buffer operation performance

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

Fixes #23164

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

Buffer fixed-width and variable-width numeric accessors now use native JSC host functions and registered buffer accessor intrinsics. JavaScript implementations and obsolete builtin helpers are removed, with tests and benchmarks covering correctness, errors, receivers, JIT behavior, multiple buffers, and access patterns.

Buffer accessor migration

Layer / File(s) Summary
Native accessor implementation and registration
src/jsc/bindings/JSBuffer.cpp
Adds native fixed-width and variable-width numeric read/write implementations, intrinsic prototype mappings, and one-time JSC accessor registration.
Builtin surface and helper removal
src/js/builtins/JSBufferPrototype.ts, src/js/builtins.d.ts, src/js/builtins/BunBuiltinNames.h, src/jsc/bindings/ZigGlobalObject.cpp
Removes fixed-width JavaScript accessors, obsolete builtin declarations and identifiers, and the checkBufferRead global binding.
Behavior coverage and performance benchmark
test/js/node/buffer.test.js, test/js/node/buffer-jit.test.ts, bench/snippets/buffer-read-write.mjs
Adds differential, error, BigInt, receiver, aliasing, dispatch, resizable-buffer, JIT tier-up, and performance benchmark coverage.

WebKit build version

Layer / File(s) Summary
WebKit version identifier
scripts/build/deps/webkit.ts
Updates the exported WebKit version string to an autobuild-preview tag.

Suggested reviewers: alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: moving Buffer read/write methods to native DFG/FTL-intrinsic functions.
Description check ✅ Passed The description covers what changed and how it was verified with benchmarks and tests, though it doesn't follow the template headings exactly.

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

…on cleanups

- ERR_OUT_OF_RANGE("value") for write* now formats the coerced number (like Node
  and the previous JS implementation), not the raw argument.
- Derive each accessor's registration descriptor from the same template arguments
  the host function is instantiated with, instead of a hand-encoded flag list.
- Tighten the new tier-up tests: assert error codes, and count mismatches instead
  of asserting per iteration (much faster under debug+ASAN).
- Trim over-long comments.
Comment thread src/js/builtins/JSBufferPrototype.ts Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp

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

Both earlier nits are addressed (9bc68f3 passes jsNumber(number) to OUT_OF_RANGE with a test for the writeInt16LE("40000") message; 922d63b removes writeU_Int8 and the dead checkBounds export). No new issues found. Deferring to a human given the scope — 32 hot-path accessors reimplemented in C++ with a new JSC intrinsic and a companion WebKit change.

Also checked: the registerBufferAccessor<T, LE, isWrite> template args line up 1:1 with each DEFINE_BUFFER_READ/WRITE instantiation; bufferAccessLoad/Store byte-flip only on BE and use WTF::unalignedLoad/Store; the write .length change from 1→2 matches Node.

Extended reasoning...

Overview

Rewrites all 32 fixed-width Buffer.prototype.read*/write* accessors (plus the 4 BigInt64 reads) from JS builtins that lazily attached a per-Buffer DataView into C++ host functions in JSBuffer.cpp, each tagged BufferAccessorIntrinsic and registered with JSC's new BufferAccessorRegistry so DFG/FTL inline them as bounds-checked loads/stores. Removes the now-dead $checkBufferRead global, writeU_Int8, and the checkBounds export. Adds tier-up correctness/error tests and a benchmark.

Follow-up on prior review

My two earlier inline nits were both addressed:

  • 9bc68f3: throwValueOutOfRange now reports the coerced jsNumber(number), not the raw argument; a writeInt16LE("40000")"Received 40000" assertion covers it.
  • 922d63b: writeU_Int8 deleted and checkBounds dropped from the default export (kept as an internal callee of checkInt).

The one candidate raised and refuted this run — write* .length moving from 1 to 2 — is correct: Node's own write* have .length === 2 (the JS builtin table's 1 was the pre-existing divergence).

Security risks

None user-facing. The memory-safety surface is the unaligned load/store on view->vector() + offset: the fast path guards offset32 >= 0 && offset32 + byteSize <= byteLength (no overflow — offset32 is a non-negative int32 widened to size_t, byteSize ≤ 8), and the slow path re-derives offset from bufferAccessCheckOffsetBounds which enforces 0 ≤ offset ≤ byteLength - byteSize in doubles before the cast. Detached buffers have byteLength() == 0, so byteLength < byteSize throws ERR_BUFFER_OUT_OF_BOUNDS before any storage is touched.

Level of scrutiny

High. This is a hot-path Node-compat rewrite in C++ that reimplements bounds/range validation from scratch, introduces a new JSC intrinsic contract (host-function behavior must exactly match what the JIT'd form OSR-exits to), depends on a companion WebKit PR, and touches raw typed-array storage. That combination warrants a maintainer's eyes even though the implementation reads correctly and the 622 existing + 62 Node parallel buffer tests pass.

Other factors

  • WTF::flipBytes is only applied when sizeof(Storage) > 1 and the accessor is BE — Bun targets are all little-endian, so LE accessors are a straight unalignedLoad/Store.
  • readFloat*/readDouble* route through JSC::purifyNaN, matching DataView semantics for NaN-encoded JSValues.
  • registerBufferAccessorsWithJSC() runs under std::call_once from JSBufferPrototype::finishCreation, so per-worker prototype creation doesn't double-register.
  • The bufferWrite slow path preserves Node's odd per-width validation order (offset-then-value for 1 byte, value-then-offset for 2/4 bytes, offset-only for floats), which the comment documents.

readIntLE/BE, readUIntLE/BE, writeIntLE/BE and writeUIntLE/BE (byteLength
1..6) become C++ host functions with the same lib/internal/buffer.js semantics,
which removes the last users of the hidden `this.$dataView` and of
internal/buffer.js (both deleted). They register with JSC as variable-width
accessors, so a call site with a constant byteLength of 1, 2 or 4 compiles to
the same bounds-checked load/store as the fixed-width methods; other widths
stay on the host function.
Comment thread src/jsc/bindings/JSBuffer.cpp Outdated
Comment thread src/js/builtins/BunBuiltinNames.h
Comment thread test/js/node/buffer-jit.test.ts Outdated

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

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 `@scripts/build/deps/webkit.ts`:
- Line 13: Update the WEBKIT_VERSION constant to use the merged WebKit commit
SHA instead of the temporary autobuild-preview-pr-330-be77ad70 tag, ensuring
prebuilt downloads remain available after the pull request closes.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4668b660-0b0a-47f9-8512-a03041a84680

📥 Commits

Reviewing files that changed from the base of the PR and between f289a67 and 77ba531.

📒 Files selected for processing (1)
  • scripts/build/deps/webkit.ts

Comment thread scripts/build/deps/webkit.ts Outdated
// unwind info (RtlAddGrowableFunctionTable) registered for the fixed JIT
// pool (LLInt pending offlineasm .seh_* emission).
export const WEBKIT_VERSION = "a40d462206e1caf8388062120acde61e37a4ae7d";
export const WEBKIT_VERSION = "autobuild-preview-pr-330-be77ad70";

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant version pin and URL construction.
git ls-files 'scripts/build/deps/webkit.ts' 'scripts/build/download.ts'
printf '\n--- scripts/build/deps/webkit.ts ---\n'
cat -n scripts/build/deps/webkit.ts
printf '\n--- scripts/build/download.ts (matching lines) ---\n'
rg -n -C 3 'autobuild-preview|WEBKIT_VERSION|prebuiltUrl|webkit' scripts/build/download.ts scripts/build/deps/webkit.ts

Repository: oven-sh/bun

Length of output: 35006


Do not pin WebKit to a preview release tag
scripts/build/deps/webkit.ts:13 uses autobuild-preview-pr-330-be77ad70, and autobuild-preview-pr-* releases are temporary. Once that PR closes, prebuilt WebKit downloads will 404; pin this to the merged WebKit commit SHA instead.

🤖 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 `@scripts/build/deps/webkit.ts` at line 13, Update the WEBKIT_VERSION constant
to use the merged WebKit commit SHA instead of the temporary
autobuild-preview-pr-330-be77ad70 tag, ensuring prebuilt downloads remain
available after the pull request closes.

Source: MCP tools

Comment thread scripts/build/deps/webkit.ts Outdated
// unwind info (RtlAddGrowableFunctionTable) registered for the fixed JIT
// pool (LLInt pending offlineasm .seh_* emission).
export const WEBKIT_VERSION = "a40d462206e1caf8388062120acde61e37a4ae7d";
export const WEBKIT_VERSION = "autobuild-preview-pr-330-be77ad70";

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.

🟡 Reminder: WEBKIT_VERSION is pinned to the ephemeral preview tag autobuild-preview-pr-330-be77ad70 for the not-yet-merged oven-sh/WebKit#330 (the PR description says the WebKit dependency is 'TBD', and commit 77ba531 confirms it's temporary). Once WebKit#330 merges, this needs to be swapped for the real merged commit hash before landing — autobuild-preview-* release artifacts are typically pruned when the underlying PR closes, at which point prebuiltUrl() will 404 and bun bd will fail for anyone without a cached extraction.

Extended reasoning...

What changed

-export const WEBKIT_VERSION = "a40d462206e1caf8388062120acde61e37a4ae7d";
+export const WEBKIT_VERSION = "autobuild-preview-pr-330-be77ad70";

The stable 40-hex commit hash is replaced with a preview-PR autobuild tag. Commit 77ba531 ("Build against the WebKit preview build for oven-sh/WebKit#330") and the PR description ("depends on the WebKit PR: TBD") both confirm this is intentional temporary scaffolding while the companion WebKit PR (which introduces BufferAccessorRegistry / BufferAccessorIntrinsic) is still open.

How the value is consumed

In scripts/build/deps/webkit.ts:

function prebuiltUrl(cfg: Config): string {
  ...
  const version = cfg.webkitVersion;
  const tag = version.startsWith("autobuild-") ? version : `autobuild-${version}`;
  return `https://github.com/oven-sh/WebKit/releases/download/${tag}/${name}.tar.gz`;
}

and prebuiltDestDir() special-cases the autobuild- prefix for cache keying:

// For 40-hex shas, 16 chars is plenty. For autobuild-preview-* tags, the
// meaningful sha is at the end, so use the whole thing.
const version16 = v.startsWith("autobuild-") ? v.slice("autobuild-".length) : v.slice(0, 16);

The file's own doc comment says the value is "From https://github.com/oven-sh/WebKit releases" — a stable release, which a preview PR tag is not.

Step-by-step: why this can't merge as-is

  1. This PR merges to main with WEBKIT_VERSION = "autobuild-preview-pr-330-be77ad70".
  2. Add DFG/FTL nodes for byte-offset scalar accessors on ArrayBufferView receivers WebKit#330 later merges (or closes); the oven-sh/WebKit release workflow prunes the autobuild-preview-pr-330-* GitHub release, as it does for all preview builds once the PR is no longer open.
  3. A contributor without a cached ~/.bun-cache/webkit-preview-pr-330-be77ad70*/ extraction runs bun bd.
  4. prebuiltUrl(cfg) resolves to https://github.com/oven-sh/WebKit/releases/download/autobuild-preview-pr-330-be77ad70/bun-webkit-<os>-<arch>....tar.gz.
  5. GitHub returns 404; the WebKit dependency fetch fails; the build aborts.

CI and anyone who already downloaded the tarball keep working (the cache-dir extraction is keyed by the full tag), which is what makes this easy to accidentally merge — it only breaks fresh builds after the preview release is deleted.

Why existing code doesn't prevent it

Nothing in scripts/build/deps/webkit.ts distinguishes a stable autobuild-<sha> release from an autobuild-preview-pr-* release beyond cache-key formatting. There's no guard that refuses to pin main to a preview tag; the autobuild- special-casing exists precisely to support this workflow during PR development.

Impact

None until the WebKit preview release is pruned; then every uncached bun bd on main fails at the WebKit download step. This is a build-infrastructure availability issue, not a runtime bug — hence nit (a merge-checklist reminder, not a defect the author is unaware of).

Fix

Once oven-sh/WebKit#330 merges, replace this line with the resulting 40-hex commit hash on oven-sh/WebKit main:

export const WEBKIT_VERSION = "<merged-commit-sha>";

(Per .claude/docs/landing-prs.md — Dependencies & vendoring.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and still the plan: the pin is currently the preview for WebKit#330's head (autobuild-preview-pr-330-6d8df126) so this PR's CI can build and test against the real dependency. It gets swapped for the merged commit hash before this lands — noted in the PR description too.

Comment thread test/js/node/buffer-jit.test.ts Outdated
Comment thread test/js/node/buffer-jit.test.ts Outdated
The bounds check used the receiver's byteLength; lib/internal/buffer.js
(and the deleted builtins) bound by this.length -- the element count --
which only equals byteLength for byte-sized views. Buffer methods .call'd
on a wider view now throw ERR_OUT_OF_RANGE where they used to, matching
Node. Also cut the in-process tier-up loop counts in buffer.test.js.
Comment on lines +3081 to +3082
// Any ArrayBufferView receiver is accepted (byte-length semantics); anything else is
// ERR_INVALID_ARG_TYPE("buf").

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.

🟡 Two stale "byte-length" artifacts survived commit 6382eeb's switch from view->byteLength() to view->length() (element count): (1) this comment's parenthetical "(byte-length semantics)" — it's now element-count (this.length) semantics, matching lib/internal/buffer.js and the test this PR added; (2) size_t byteLengthOfView = view->length(); at :3379, whose name now lies — the sibling sites at :3171/:3250 were renamed to viewLength in the same commit but this one was missed. No functional effect; the parenthetical could be dropped or reworded, and the local renamed to viewLength.

Extended reasoning...

What went stale

Commit 6382eeb ("Bound the accessors by the receiver's element count, trim tier-up loops") changed every bounds check in the new accessor block from view->byteLength() to view->length() — the receiver's element count. Two artifacts from before that commit were not updated to match:

(1) The block comment at JSBuffer.cpp:3081:

// Any ArrayBufferView receiver is accepted (byte-length semantics); anything else is
// ERR_INVALID_ARG_TYPE("buf").

The parenthetical "(byte-length semantics)" is now false. Every call site in the block passes view->length(): bufferRead at :3171 and :3188, bufferWrite at :3250 and :3281, bufferReadVarWidthOffset at :3379, bufferWriteVarWidth at :3493. And this PR's own test in buffer.test.js explicitly documents and asserts the opposite of the comment:

// The bound is the receiver's element count (this.length), as in lib/internal/buffer.js, not
// its byteLength: on a wider-element view these throw even though the bytes would fit.
const u16 = new Uint16Array(4);
expect(codeOf(() => Buffer.prototype.readInt32LE.call(u16, 3))).toBe("ERR_OUT_OF_RANGE");

(2) The local at JSBuffer.cpp:3379:

size_t byteLengthOfView = view->length();

The variable is named byteLengthOfView but holds the element count. Commit 6382eeb renamed the identically-computed locals in bufferRead (:3171) and bufferWrite (:3250) to viewLength, and the parameter of bufferAccessCheckOffsetBounds (:3105) is also viewLength — so this one local is both factually misnamed and inconsistent with the file's own convention that the same commit established.

Step-by-step proof

Take Buffer.prototype.readInt32LE.call(new Uint16Array(4), 3):

  1. The receiver is a Uint16Array of 4 elements — byteLength() == 8, length() == 4.
  2. bufferRead<int32_t, true> computes viewLength = view->length() = 4 (:3171).
  3. Fast-path check: 3 + 4 <= 4 is false → falls to slow path.
  4. bufferAccessCheckOffsetBounds(..., viewLength=4, byteSize=4) computes maxOffset = 0; 3 <= 0 is false → throws ERR_OUT_OF_RANGE("offset", ">= 0 and <= 0", 3).
  5. Under the comment's claimed "byte-length semantics", step 2 would have used byteLength() = 8, maxOffset = 4, and offset 3 would have been in bounds — no throw. The test at buffer.test.js asserts it does throw, so the code is right and the comment is wrong.

For the local: at :3379 with the same receiver, byteLengthOfView = view->length() = 4, not 8. The name says "byteLength"; the value is the element count.

Why nothing else catches it

Both are documentation-only. The comment isn't compiled; the variable's numeric value is correct (only its name lies), and only that value flows into the makeString(">= 0 and <= "_s, byteLengthOfView - byteLength) at :3390 — the identifier itself is invisible to users. So there is no test that could fail; this is purely REVIEW.md hygiene: "Comments carry only durable non-obvious content" and "Name things truthfully" / "Match the exact file's local conventions". A comment that contradicts the code it labels is worse than none.

Impact

None functional — nit. The runtime behavior, error messages, and JIT semantics are all correct as-is; this is a mechanical rename and a comment cleanup that the same commit (6382eeb) should have carried.

Fix

  • :3081: drop the parenthetical, or reword to e.g. "(bounded by the receiver's length, i.e. element count — matching lib/internal/buffer.js)".
  • :3379: rename byteLengthOfViewviewLength (three uses at :3379/:3380/:3386/:3390) to match :3171/:3250 and the bufferAccessCheckOffsetBounds parameter.

Comment on lines +3146 to +3152
template<typename Storage, bool isLittleEndian>
static ALWAYS_INLINE void bufferAccessStore(uint8_t* address, Storage value)
{
if constexpr (!isLittleEndian && sizeof(Storage) > 1)
value = WTF::flipBytes(value);
WTF::unalignedStore<Storage>(address, value);
}

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.

🟡 The new bufferAccessStore<uint64_t, true/false> (:3146-3152) is functionally identical to the pre-existing write_int64_le / write_int64_be helpers just above at :3048-3074 — both do an unaligned 8-byte store, optionally byte-swapped, on a host the file already static_asserts is little-endian. The old helpers' only callers are the four writeBig{Int,UInt}64{LE,BE} bodies at :3565/:3595/:3624/:3653, which this PR already brings into the accessor system (adds BufferAccessorIntrinsic + registerBufferAccessor<int64_t/uint64_t, ..., true>); those bodies can call bufferAccessStore<uint64_t, ...> and the two old helpers can be deleted — same consolidation class as the writeU_Int8 / $dataView cleanups already applied in 922d63b and f289a67.

Extended reasoning...

What's duplicated

This PR introduces bufferAccessStore<Storage, isLittleEndian> at JSBuffer.cpp:3146-3152:

template<typename Storage, bool isLittleEndian>
static ALWAYS_INLINE void bufferAccessStore(uint8_t* address, Storage value)
{
    if constexpr (!isLittleEndian && sizeof(Storage) > 1)
        value = WTF::flipBytes(value);
    WTF::unalignedStore<Storage>(address, value);
}

For Storage = uint64_t, this is byte-for-byte equivalent to the pre-existing helpers immediately above at :3048-3074:

template<typename I> void write_int64_le(uint8_t* buffer, I value)
{
    static_assert(std::endian::native == std::endian::little);
    auto val = reinterpret_cast<uint8_t*>(&value);
    buffer[0] = val[0]; ... buffer[7] = val[7];
}
template<typename I> void write_int64_be(uint8_t* buffer, I value)
{
    static_assert(std::endian::native == std::endian::little);
    auto val = reinterpret_cast<uint8_t*>(&value);
    buffer[0] = val[7]; ... buffer[7] = val[0];
}

Both do an unaligned 8-byte store, optionally byte-swapped. The file already static_asserts a little-endian host at :3050 and :3064, so WTF::flipBytes + WTF::unalignedStore<uint64_t> is the same operation as the manual byte-by-byte reversed copy.

Why this belongs in the PR

Grep confirms write_int64_le / write_int64_be have exactly four callers, all in this file: the four jsBufferPrototypeFunction_writeBig{Int,UInt}64{LE,BE} bodies at :3565, :3595, :3624, :3653. This PR does touch those four functions — it changes their HashTable entries from NoIntrinsic to BufferAccessorIntrinsic and registers them via registerBufferAccessor<int64_t/uint64_t, ..., true> in registerBufferAccessorsWithJSC(), bringing them into the same accessor system whose store primitive is bufferAccessStore.

REVIEW.md, Architecture & layering: "One implementation, in the right place. Never copy a helper … share or derive it … when your change supersedes a mechanism, delete the old path in the same PR." And Code style: "If your fix makes two functions byte-identical, delete one." This is the same cleanup class already applied three times in this PR: writeU_Int8 / checkBounds in 922d63b, and the bufferWriteVarWidthOffset inline + $dataView removal in f289a67.

Why nothing prevents it

bufferAccessStore lives in an anonymous namespace, so it has internal linkage but is visible throughout the translation unit — including at :3565-3653 where write_int64_le/be are called. The four call sites can invoke it directly (with a static_cast<uint64_t>(value) at the two int64_t sites, which preserves the bit pattern).

Step-by-step proof of equivalence

Take Buffer.alloc(8).writeBigInt64BE(-1n, 0):

  1. jsBufferPrototypeFunction_writeBigInt64BE (:3569) validates the receiver, coerces -1nint64_t value = -1 (bit pattern 0xFFFF_FFFF_FFFF_FFFF), validates offset = 0.
  2. Old path (:3595): write_int64_be(vector + 0, value) reinterprets &value as uint8_t*, then buffer[0] = val[7], ..., buffer[7] = val[0]. On the LE host, val[0..7] = FF FF FF FF FF FF FF FF; the reversed copy writes FF FF FF FF FF FF FF FF.
  3. New path: bufferAccessStore<uint64_t, false>(vector + 0, static_cast<uint64_t>(value)). static_cast<uint64_t>(-1) = 0xFFFF_FFFF_FFFF_FFFF; WTF::flipBytes reverses to the same value; WTF::unalignedStore<uint64_t> writes FF FF FF FF FF FF FF FF.
  4. Same for a non-symmetric value like 0x0102030405060708: old writes 01 02 03 04 05 06 07 08; new flips to 0x0807060504030201 and stores it LE-native → the same bytes 01 02 03 04 05 06 07 08.

The LE case is trivially identical (unaligned host-order store either way). Same argument for the writeBigUInt64* pair with uint64_t.

Impact

None functional — the two implementations produce identical bytes. This is purely duplicate-implementation cleanup; hence nit.

Fix

Replace the four call sites, e.g. at :3565:

bufferAccessStore<uint64_t, true>(static_cast<uint8_t*>(castedThis->vector()) + offset, static_cast<uint64_t>(value));

(and <uint64_t, false> for the BE pair), then delete write_int64_le / write_int64_be at :3048-3074.

Comment on lines +128 to +133
const counting = { valueOf() { coerced++; return 300; } };
for (let i = 0; i < 1000; i++) {
try { scratch.writeInt8(counting, 0); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", "range code"); }
try { scratch.writeInt8(counting, 100); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", "value checked before offset for 1-byte writes? no: offset first"); }
}
assert(coerced === 2000, "value coerced exactly once per call, even when it then throws: " + coerced);

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.

🟡 The two try { scratch.writeInt8(counting, …); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", …); } lines have no assert(false, "should throw") after the writeInt8 call, so if the value range check were broken and writeInt8(300, 0) silently succeeded, valueOf still runs once per call (coercion precedes the range check), coerced still hits 2000, the catch never fires, and the block passes — even though the assertion message says it's testing behavior 'even when it then throws'. This is REVIEW.md's flagged 'expects inside catch blocks that may never fire' pattern; the same test uses the correct try { f(); assert(false, "should throw"); } catch convention at :142/:151/:152, so these two are the only lines that drifted. Nit only: the range-check-throws behavior is separately covered non-vacuously in this PR's buffer.test.js additions (ranges === 6000).

Extended reasoning...

What the block does

In the "host semantics survive every exit path" test (test/js/node/buffer-jit.test.ts:127-133):

let coerced = 0;
const counting = { valueOf() { coerced++; return 300; } };
for (let i = 0; i < 1000; i++) {
  try { scratch.writeInt8(counting, 0); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", "range code"); }
  try { scratch.writeInt8(counting, 100); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", "...offset first"); }
}
assert(coerced === 2000, "value coerced exactly once per call, even when it then throws: " + coerced);

Neither try body has an assert(false, "should throw") after the writeInt8 call. The catch-block assertions are the only place that verifies a throw occurred — and if the call doesn't throw, the catch simply never runs.

Why this is the flagged pattern

REVIEW.md, Every assertion must be able to fail: "Hunt vacuous patterns: … expects inside catch blocks or callbacks that may never fire". These two lines are exactly that shape. The same test uses the correct file convention ~10 lines below:

try { f(); assert(false, what + " should throw"); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", ...); }   // :142
try { bb.writeBigInt64LE(2n ** 63n, 0); assert(false, "should throw"); } catch (e) { ... }                       // :151
try { bb.writeBigUInt64LE(-1n, 0); assert(false, "should throw"); } catch (e) { ... }                            // :152

so lines 130-131 are the only two that drifted from the pattern the rest of the file follows.

Step-by-step: why the block passes even if the range check is broken

Suppose bufferWrite<int8_t>'s valueIsInRange() were deleted (i.e. writeInt8(300, 0) no longer throws). scratch = Buffer.alloc(8).

  1. Line 130scratch.writeInt8(counting, 0): In bufferWrite<int8_t> (JSBuffer.cpp), valueValue.toNumber() runs first → counting.valueOf() fires → coerced++, returns 300. With the broken range check, no throw; 300 is truncated via JSC::toInt32 and stored at offset 0. Catch never fires. Nothing fails.
  2. Line 131scratch.writeInt8(counting, 100): valueOf runs again → coerced++. With a broken value range check, control still reaches bufferAccessCheckOffsetBounds(…, viewLength=8, byteSize=1), which throws ERR_OUT_OF_RANGE("offset") because 100 > maxOffset 7. Catch fires, and e.code === "ERR_OUT_OF_RANGE" passes — same code, wrong reason (offset, not value).
  3. After 1000 iterations: coerced === 2000. The final assertion at :133 passes.

Result: the whole block is green even though the int8 value range check is completely gone — yet the assertion message at :133 explicitly claims to be verifying behavior "even when it then throws".

Why nothing else in this block prevents it

Coercion order is what makes the coerced === 2000 assertion insensitive to the throw: bufferWrite calls toNumber() (which invokes valueOf) before valueIsInRange() and before the offset bounds check, so valueOf runs exactly once per call whether or not the call subsequently throws. The coerced === 2000 assertion IS still meaningful (it catches double-coercion or skipped-coercion bugs) — it just doesn't test what its own message says about throwing.

Impact

Nit. The value-range-throws property IS covered non-vacuously elsewhere in this PR: test/js/node/buffer.test.js's new "writes match a DataView…" test does if (codeOf(() => buf.writeInt8(128, 0)) === "ERR_OUT_OF_RANGE") ranges++; … expect(ranges).toBe(6000) across 2000 iterations, which fails if the range check is removed. So the suite as a whole still catches a regression; this block just doesn't test what its own message claims, and is inconsistent with the file's own convention.

Fix

Match the file's own pattern at :142/:151/:152:

try { scratch.writeInt8(counting, 0); assert(false, "should throw"); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", "range code"); }
try { scratch.writeInt8(counting, 100); assert(false, "should throw"); } catch (e) { assert(e.code === "ERR_OUT_OF_RANGE", "..."); }

(with the assert(false) guarded by e.message.includes("should throw") in the catch, or by re-throwing when e.code is undefined — the file's :142 pattern relies on the thrown Error having no .code, so e.code === "ERR_OUT_OF_RANGE" fails and the catch's own assert catches it.)

Jarred-Sumner and others added 3 commits July 25, 2026 02:14
…ts, byteLength dispatch, and BigInt writers

- A DataView receiver has no `length` in lib/internal/buffer.js, so every
  accessor now reports the `>= 0 and <= NaN` ERR_OUT_OF_RANGE that Node and
  the deleted builtins produce, instead of reading/writing through byteLength.
- The variable-width readers validated offsets with a validateInteger()
  fallback that printed the safe-integer-range message; Node's readIntLE
  family dispatches to the width-specific readers, whose validation is
  validateNumber() + boundsError(). Reuse the shared type/bounds helpers, so
  out-of-range integral offsets (including |offset| > 2^53) get the bounds
  message.
- The variable-width writers coerced the value before validating byteLength,
  invoking user valueOf() where Node never does; coerce after the dispatch.
- The BigInt writers keep their value validation but now go through the
  shared receiver check and element-count bound, so writeBigInt64* and
  readBigInt64* agree on the receiver and throw coded errors like siblings.

Also give the subprocess-based JIT tests an explicit per-test timeout.
Comment thread src/jsc/bindings/JSBuffer.cpp Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp Outdated
Jarred-Sumner and others added 3 commits July 25, 2026 04:17
The buffer accessor stress tests in the WebKit fork's JSTests never run in
CI, since oven's WebKit CI only builds. Bring the coverage that has no Bun
equivalent into buffer-jit.test.ts so it runs on every PR:

- A differential fuzzer: one seeded operation stream over all read*/write*
  accessors runs in a JIT process and a BUN_JSC_useJIT=0 process, and the
  digests of every return value, error and post-write buffer byte must
  match. The receiver pool avoids SharedArrayBuffer, which the interpreter-
  only mode does not expose, so both arms build the identical stream.
- A >2GB receiver that must stay optimized (bounded compiles) while
  out-of-bounds accesses keep throwing.
- Views with 2GB and ~4GB byteOffsets, cross-checked against a DataView on
  the raw buffer.

The run() helper takes per-test env. Verified the fuzzer detects an
injected JIT-arm divergence (digest mismatch) before trusting a pass.
Comment thread src/jsc/bindings/JSBuffer.cpp Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp Outdated
Comment thread src/jsc/bindings/JSBuffer.cpp

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

No new issues found — the last two ordering nits (BigInt writer receiver-vs-value/offset, and the Received undefined on the DataView branch) are addressed in 2a8c837 and pinned by tests. Deferring to a human because of scope: ~600 lines of new hot-path C++ in JSBuffer.cpp backed by a new JSC intrinsic, and WEBKIT_VERSION is pinned to the oven-sh/WebKit#330 preview build, so merge order with that PR needs a human call.

What was reviewed:

  • All four accessor families (bufferRead<T>, bufferWrite<T>, bufferReadVarWidth, bufferWriteVarWidth, and the four BigInt writers) now share the same offset-type → receiver → bounds validation order; the BigInt writers' deferred-receiver shape matches bufferWrite<T>.
  • validateOffsetBigInt64's new !viewHasLength branch reports jsNumber(offsetD) so an omitted offset formats as 0.
  • The byteLength()length() switch in the BigInt writers is intentional (element-count semantics, matching lib/internal/buffer.js and the fixed-width family) and is covered by the Uint16Array receiver test.
  • registerBufferAccessorsWithJSC() template args match each host function's <T, isLittleEndian> instantiation one-for-one.
Extended reasoning...

Overview

This PR replaces ~40 Buffer.prototype.read*/write* JS builtins with C++ host functions in JSBuffer.cpp, each carrying BufferAccessorIntrinsic and registered via a new BufferAccessorRegistry so DFG/FTL can compile call sites into bounds-checked loads/stores. It deletes the $dataView/$checkBufferRead private machinery and internal/buffer.ts, updates the prototype hash table, and adds ~800 lines of tests (a JIT-behavior suite with a differential fuzzer against useJIT=0, plus tier-up coverage in buffer.test.js). WEBKIT_VERSION is bumped to a preview build of oven-sh/WebKit#330, which supplies the intrinsic and registry.

Security risks

None identified. The new host functions bounds-check via view->length() before every view->vector() access, use WTF::unalignedLoad/Store with explicit byte-swap, and all size/offset arithmetic widens to size_t/double before comparison. No user-controlled data reaches allocation or the filesystem. The toNumber coercion (which can run user JS) happens before any pointer is dereferenced, and RETURN_IF_EXCEPTION follows it.

Level of scrutiny

High. This is hot-path native code on one of the most-called Node APIs, with a new JIT intrinsic whose correctness depends on the host function and the registered DataViewData descriptor agreeing exactly (a mismatch would produce wrong results only after tier-up). It also pins the build to an unmerged WebKit PR's preview artifact — merging this before WebKit#330 lands (or without swapping the pin to a real hash) would leave main on a preview tag. That's a merge-ordering decision a maintainer should make.

Other factors

The PR has been through six rounds of review on argument-validation ordering in error paths (all nit-level, all resolved and pinned by tests verified against Node v26.5.0). This run's bug-hunting pass found nothing new. Test coverage is unusually thorough — differential fuzzing of every accessor against a JIT-off reference, cross-view aliasing / CSE ordering, resizable/growable receivers, >2GB/>4GB-byteOffset views, and prototype/instance shadowing after tier-up — and the JSC side ran the stress suite under 20 fuzzing configurations. Nonetheless the combination of new C++ template machinery, a new JSC intrinsic, and a cross-repo dependency is well past the bar for auto-approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants