Skip to content

buffer: match Node's Buffer.prototype.fill offset and end argument handling - #33033

Merged
Jarred-Sumner merged 8 commits into
mainfrom
farm/ba6603b8/buffer-fill-arg-reinterpretation
Jun 30, 2026
Merged

Jarred-Sumner merged 8 commits into
mainfrom
farm/ba6603b8/buffer-fill-arg-reinterpretation

Conversation

@robobun

@robobun robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

Buffer.prototype.fill diverges from Node on several argument shapes. Most of them silently produce a wrong fill range (no error), and a few throw where Node does not.

Reproduction

// value is NOT a string: Node never reinterprets a string offset/end as the encoding
Buffer.alloc(5, 0xaa).fill(0, "1", 3);         // bun <00 00 00 aa aa>       node ERR_INVALID_ARG_TYPE
Buffer.alloc(5, 0xaa).fill(0, 1, "3");         // bun <aa 00 00 00 00>       node ERR_INVALID_ARG_TYPE
Buffer.alloc(5, 0xaa).fill(0, "hex");          // bun <00 00 00 00 00>       node ERR_INVALID_ARG_TYPE

// Node never reads (or validates) `end` when `offset` is undefined
Buffer.alloc(5, 0xaa).fill(0, undefined, "3"); // bun ERR_INVALID_ARG_TYPE   node <00 00 00 00 00>
Buffer.alloc(5, 0xaa).fill(0, undefined, 3);   // bun <00 00 00 aa aa>       node <00 00 00 00 00>

// fill(string, encoding) has no `end` slot, so anything there is discarded
Buffer.alloc(5, 0xaa).fill("b", "utf8", 3);    // bun <62 62 62 aa aa>       node <62 62 62 62 62>
Buffer.alloc(5, 0xaa).fill("b", undefined, 3); // bun <62 62 62 aa aa>       node <62 62 62 62 62>

// Argument counts the intake did not handle
Buffer.alloc(3, 0xaa).fill();                     // bun no-op <aa aa aa>    node <00 00 00>
Buffer.alloc(5, 0xaa).fill(0, 1, 3, "utf8", "x"); // bun <00 00 00 00 00>    node <aa 00 00 aa aa>

// A null or empty-string encoding is the same as an absent one (utf8)
Buffer.alloc(5, 0xaa).fill("a", 1, 3, null);   // bun ERR_INVALID_ARG_TYPE   node <aa 61 61 aa aa>
Buffer.alloc(5, 0xaa).fill("a", 1, 3, "");     // bun ERR_UNKNOWN_ENCODING   node <aa 61 61 aa aa>
Buffer.alloc(3, "a", null);                    // bun ERR_INVALID_ARG_TYPE   node <61 61 61>
Buffer.alloc(3, "a", "");                      // bun ERR_UNKNOWN_ENCODING   node <61 61 61>

The first case is the one a caller is most likely to hit by accident: buf.fill(0, "1", 3) looks like a typo'd offset and Node rejects it, but Bun quietly treats "1" as an encoding, drops it (the value is not a string), and fills [0, 3).

Cause

jsBufferPrototypeFunction_fillBody unconditionally moves a string offset (or, failing that, a string end) into the encoding slot, and always validates end. In Node's _fill (lib/buffer.js) that reinterpretation lives inside if (typeof value === 'string'), the offset-slot branch also resets end = buf.length, and end is only validated once offset is present:

if (typeof value === 'string') {
  if (offset === undefined || typeof offset === 'string') {
    encoding = offset;  offset = 0;  end = buf.length;
  } else if (typeof end === 'string') {
    encoding = end;  end = buf.length;
  }
  // ... encoding validation, single-char fast path ...
}
// ...
if (offset === undefined) {
  offset = 0;
  end = buf.length;
} else {
  validateOffset(offset, 'offset');
  if (end === undefined) end = buf.length;
  else validateOffset(end, 'end', 0, buf.length);
  if (offset >= end) return buf;
}

For a non-string value the string stays put and validateOffset rejects it with ERR_INVALID_ARG_TYPE; Bun consumed it as a (then-unused) encoding instead. And because Bun validated end even with no offset, fill(0, undefined, "3") threw where Node accepts.

Three more pre-existing shapes in the same intake were flagged in review and folded in:

  • fill() with no arguments took an early return instead of Node's path, where the undefined value reaches the numeric branch and coerces to 0.
  • The argument switch had cases for 2, 3, and 4 arguments but none for 5 or more, so a stray extra argument made every slot degrade to undefined and the whole buffer got filled.
  • Node's normalizeEncoding treats exactly undefined, null, and "" as an absent encoding (utf8). Bun's encoding gate only skipped undefined, so null and "" reached parseEncoding and threw. Buffer.alloc(size, fill, encoding) has the identical gate, so both now go through one shared resolveEncodingString helper.

Fix

Mirror Node's rules in jsBufferPrototypeFunction_fillBody:

  • gate the string-offset/end to encoding reinterpretation on value.isString(), and discard end in the offset-slot branch (Node's end = buf.length);
  • nest the end validation inside the offset-present branch so end is never read without an offset;
  • replace the argument switch and the zero-argument early return with the bounds-checked callFrame->argument(n) (the idiom jsBufferPrototypeFunction_writeBody already uses), which returns undefined for an absent argument and ignores trailing ones;
  • route the encoding gate in fill() and Buffer.alloc() through a new resolveEncodingString helper covering undefined, null, and "". parseEncoding itself is unchanged on purpose: buf.toString(null) and buf.toString("") must stay ERR_UNKNOWN_ENCODING, because Node routes toString through getEncodingOps, which rejects both.

The error-ordering contract is unchanged: for a string value, a bad encoding still wins over a bad range (fill("a", -1, 0, "bogus") is still ERR_UNKNOWN_ENCODING).

Deliberately excluded

Two adjacent divergences turned up while mapping this. Each is real, pre-existing, and left for its own change rather than half-fixed here:

  • Buffer.from("ab", "") throws ERR_UNKNOWN_ENCODING where Node returns <61 62>. Node's fromString treats any non-string or empty-string encoding as utf8 there, and Bun already matches it for every non-string value (null, 5, false, {}); only "" diverges. Its gate lives outside this file's fill/alloc path.
  • A non-primitive encoding whose toString yields a valid encoding name is honored by Bun but ignored or rejected by Node: fill("ab", 0, 4, new String("utf16le")) is <61 62 61 62 aa> (utf8) in Node and <61 00 62 00 aa> (utf16le) in Bun, and fill("ab", 0, 4, {toString: () => "hex"}) throws ERR_INVALID_ARG_TYPE in Node but is honored in Bun. Bun's parseEncoding coerces its argument via toStringOrNull before any type check. Changing that touches six callers, and the coercion is load-bearing for the TOCTOU-hardening suite in test/js/node/buffer-copy-fill-detach.test.ts, which deliberately documents this divergence and detaches the buffer from inside the encoding object's toString to prove fill survives. That behavior and that safety suite have to change together.

Verification

  • Node/Bun differential test: test/js/node/buffer-fill-args-fixture.js enumerates 48 fill() argument shapes and prints the resulting bytes or the thrown error class + code for each. buffer.test.js spawns the fixture under nodeExe() and bunExe() and requires the two outputs to be byte-identical. With this PR they match exactly; without the src/ change they diverge on every shape the PR targets. The fixture's Node output is identical between v24.3.0 (the version the CI image pins) and v26.3.0.
  • test/js/node/buffer.test.js: all existing tests pass; the new Buffer.fill offset/end argument handling describe adds 11 tests, of which 8 fail on the unfixed build (the other 3 are controls pinning behavior that already matched Node).
  • Vendored Node suite: test-buffer-fill.js, test-buffer-alloc.js, test-buffer-write.js, test-buffer-write-fast.js all pass.
  • test/js/node/buffer-copy-fill-detach.test.ts (detach-during-fill and detach-from-encoding-toString) passes unchanged.
Fixture output (Node v24.3.0 == Node v26.3.0 == Bun with this PR)
fill(0, "1", 3)                              throws TypeError ERR_INVALID_ARG_TYPE
fill(0, 1, "3")                              throws TypeError ERR_INVALID_ARG_TYPE
fill(0, "hex")                               throws TypeError ERR_INVALID_ARG_TYPE
fill(0, "bogus")                             throws TypeError ERR_INVALID_ARG_TYPE
fill(true, "1", 3)                           throws TypeError ERR_INVALID_ARG_TYPE
fill(uint8, "1", 3)                          throws TypeError ERR_INVALID_ARG_TYPE
fill(uint8, 1, "3")                          throws TypeError ERR_INVALID_ARG_TYPE
fill(0, null, 3)                             throws TypeError ERR_INVALID_ARG_TYPE
fill(0, 1, null)                             throws TypeError ERR_INVALID_ARG_TYPE
fill(0, undefined, "3")                      ok     0000000000
fill(0, undefined, 3)                        ok     0000000000
fill(0, undefined, null)                     ok     0000000000
fill(0, undefined, -1)                       ok     0000000000
fill("b", undefined, 3)                      ok     6262626262
fill("b", "utf8", 3)                         ok     6262626262
fill("ab", undefined, undefined, "utf16le")  ok     6162616261
fill("a", undefined, undefined, "bogus")     ok     6161616161
fill("ab", 0, undefined, "utf16le")          ok     6100620061
fill("a", 1, undefined, "bogus")             throws TypeError ERR_UNKNOWN_ENCODING
fill("a", 1, 3, null)                        ok     aa6161aaaa
fill("a", undefined, undefined, null)        ok     6161616161
fill("a", 1, 3, "")                          ok     aa6161aaaa
fill("a", "")                                ok     6161616161
fill("a", 1, "")                             ok     aa61616161
fill()                                       ok     0000000000
fill(undefined)                              ok     0000000000
fill(0, 1, 3, "utf8", "x")                   ok     aa0000aaaa
fill("b", 1, 3, "utf8", "x")                 ok     aa6262aaaa
fill("ab", 0, 4, "utf16le", "x")             ok     61006200aa
fill(0)                                      ok     0000000000
fill(0, 1)                                   ok     aa00000000
fill(0, 1, 3)                                ok     aa0000aaaa
fill(0, 1.5, 3)                              throws RangeError ERR_OUT_OF_RANGE
fill(0, -1, 3)                               throws RangeError ERR_OUT_OF_RANGE
fill(0, 1, 99)                               throws RangeError ERR_OUT_OF_RANGE
fill(0, NaN, 3)                              throws RangeError ERR_OUT_OF_RANGE
fill(0, 1, 3, "bogus")                       ok     aa0000aaaa
fill("b")                                    ok     6262626262
fill("b", "utf8")                            ok     6262626262
fill("b", 1)                                 ok     aa62626262
fill("b", 1, 3)                              ok     aa6262aaaa
fill("b", 1, "utf8")                         ok     aa62626262
fill("b", 1, 3, "utf8")                      ok     aa6262aaaa
fill("b", "1", 3)                            throws TypeError ERR_UNKNOWN_ENCODING
fill("b", 1, "3")                            throws TypeError ERR_UNKNOWN_ENCODING
fill("b", "bogus")                           throws TypeError ERR_UNKNOWN_ENCODING
fill("b", -1)                                throws RangeError ERR_OUT_OF_RANGE
fill("abc", "hex")                           ok     ababababab

…ndling

Buffer.prototype.fill reinterprets a string `offset` or `end` argument
as the encoding regardless of the fill value's type, and always
validates `end`. Node's _fill (lib/buffer.js) only performs that
reinterpretation when the fill value is itself a string, and only reads
`end` once `offset` is present. Eight argument shapes diverge:

  buf.fill(0, "1", 3)          // bun fills [0,3);  node ERR_INVALID_ARG_TYPE
  buf.fill(0, 1, "3")          // bun fills [1,len); node ERR_INVALID_ARG_TYPE
  buf.fill(0, undefined, "3")  // bun ERR_INVALID_ARG_TYPE; node fills all
  buf.fill(0, undefined, 3)    // bun fills [0,3);  node fills all
  buf.fill("b", "utf8", 3)     // bun fills [0,3);  node fills all

Gate the string-offset/end to encoding reinterpretation on
value.isString(), discard `end` in the offset-slot branch to match
Node's `end = buf.length` reset, and nest the `end` validation inside
the offset-present branch so it is never read without an `offset`.
@coderabbitai

coderabbitai Bot commented Jun 29, 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: b3caf3ef-c20f-4964-b96d-2f3ef379f323

📥 Commits

Reviewing files that changed from the base of the PR and between 97c4054 and ba95354.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSBuffer.cpp
  • test/js/node/buffer.test.js

Walkthrough

Buffer.fill and buffer construction now treat absent encodings consistently, and fill() argument parsing was adjusted for Node-compatible handling of no arguments, string slots, ignored extra arguments, and offset/end validation. New fixture-based tests cover the updated behavior.

Changes

Buffer.fill Argument Handling Fix

Layer / File(s) Summary
Absent encoding handling
src/jsc/bindings/JSBuffer.cpp
Adds absent-encoding resolution and uses it in buffer construction and fill encoding parsing.
fillBody argument handling
src/jsc/bindings/JSBuffer.cpp
Changes fill() to accept the no-argument case, read fixed positional slots, reinterpret string slots as encoding only in the string-value path, and skip end validation when offset is omitted.
Fixture and fill() tests
test/js/node/buffer-fill-args-fixture.js, test/js/node/buffer.test.js
Adds a deterministic fill-argument fixture and a test suite that checks Node-parity output and argument-shape behavior.

Possibly related PRs

  • oven-sh/bun#32784: Directly modifies the same Buffer.prototype.fill argument validation path in src/jsc/bindings/JSBuffer.cpp, overlapping at offset/end integer checking and string interpretation logic.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: matching Node's Buffer.fill offset/end handling.
Description check ✅ Passed The description covers the change and verification, even though it uses custom headings instead of the template's exact section names.
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.

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

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:25 PM PT - Jun 29th, 2026

❌ @robobun, your commit ba95354 has 2 failures in Build #67024 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33033

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

bun-33033 --bun

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Add tests that run in Node.js and Bun that verify t he output is the same.

The new fixture enumerates 34 Buffer.prototype.fill argument shapes and
prints the resulting bytes or the thrown error class and code.
buffer.test.js spawns it under nodeExe() and bunExe() and requires the
two outputs to be byte-identical. Node 24.3.0 (the version the CI image
pins) and 26.3.0 agree on every line.
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 274affb.

test/js/node/buffer-fill-args-fixture.js enumerates 34 Buffer.prototype.fill argument shapes and prints the resulting bytes, or the thrown error class + code, for each. buffer.test.js now spawns that fixture under both nodeExe() and bunExe() and requires the two outputs to be byte-identical.

With this PR the outputs match exactly. Against the unfixed build, 14 of the 34 lines differ, so the test catches every shape this PR changes. I also confirmed the fixture's Node output is identical between v24.3.0 (the version the CI test image pins in .buildkite/Dockerfile) and v26.3.0, so the differential is stable against the CI Node.

Fixture output (Node v24.3.0 == Node v26.3.0 == Bun with this PR)
fill(0, "1", 3)            throws TypeError ERR_INVALID_ARG_TYPE
fill(0, 1, "3")            throws TypeError ERR_INVALID_ARG_TYPE
fill(0, "hex")             throws TypeError ERR_INVALID_ARG_TYPE
fill(0, "bogus")           throws TypeError ERR_INVALID_ARG_TYPE
fill(true, "1", 3)         throws TypeError ERR_INVALID_ARG_TYPE
fill(uint8, "1", 3)        throws TypeError ERR_INVALID_ARG_TYPE
fill(uint8, 1, "3")        throws TypeError ERR_INVALID_ARG_TYPE
fill(0, null, 3)           throws TypeError ERR_INVALID_ARG_TYPE
fill(0, 1, null)           throws TypeError ERR_INVALID_ARG_TYPE
fill(0, undefined, "3")    ok     0000000000
fill(0, undefined, 3)      ok     0000000000
fill(0, undefined, null)   ok     0000000000
fill(0, undefined, -1)     ok     0000000000
fill("b", undefined, 3)    ok     6262626262
fill("b", "utf8", 3)       ok     6262626262
fill(0)                    ok     0000000000
fill(0, 1)                 ok     aa00000000
fill(0, 1, 3)              ok     aa0000aaaa
fill(0, 1.5, 3)            throws RangeError ERR_OUT_OF_RANGE
fill(0, -1, 3)             throws RangeError ERR_OUT_OF_RANGE
fill(0, 1, 99)             throws RangeError ERR_OUT_OF_RANGE
fill(0, NaN, 3)            throws RangeError ERR_OUT_OF_RANGE
fill(0, 1, 3, "bogus")     ok     aa0000aaaa
fill("b")                  ok     6262626262
fill("b", "utf8")          ok     6262626262
fill("b", 1)               ok     aa62626262
fill("b", 1, 3)            ok     aa6262aaaa
fill("b", 1, "utf8")       ok     aa62626262
fill("b", 1, 3, "utf8")    ok     aa6262aaaa
fill("b", "1", 3)          throws TypeError ERR_UNKNOWN_ENCODING
fill("b", 1, "3")          throws TypeError ERR_UNKNOWN_ENCODING
fill("b", "bogus")         throws TypeError ERR_UNKNOWN_ENCODING
fill("b", -1)              throws RangeError ERR_OUT_OF_RANGE
fill("abc", "hex")         ok     ababababab

@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 `@src/jsc/bindings/JSBuffer.cpp`:
- Around line 1356-1367: The JSBuffer::_fill argument rewrite is incorrectly
treating an omitted offset as if it were the encoding slot, which clobbers the
explicit 4th-argument encoding. Update the string-value handling so only a
string offset is reassigned to encoding; when offsetValue is undefined, leave
encodingValue untouched and only clear endValue, preserving calls like fill with
an explicit encoding in the 4th argument.
🪄 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: 533618be-85b0-4813-a4d0-476b337d55bb

📥 Commits

Reviewing files that changed from the base of the PR and between 20d649f and 274affb.

📒 Files selected for processing (3)
  • src/jsc/bindings/JSBuffer.cpp
  • test/js/node/buffer-fill-args-fixture.js
  • test/js/node/buffer.test.js

Comment thread src/jsc/bindings/JSBuffer.cpp Resolved
Comment thread src/jsc/bindings/JSBuffer.cpp Resolved
Two more argument-count shapes in fill()'s intake diverge from Node:

  Buffer.alloc(3, 0xaa).fill()   // bun no-op <aa aa aa>; node zero-fills
  Buffer.alloc(5, 0xaa).fill(0, 1, 3, "utf8", "x")
                                 // bun fills [0, 5); node fills [1, 3)

The zero-argument early return skipped Node's path, where the undefined
value reaches the numeric branch and coerces to 0. The argument switch
had no case for five or more arguments, so every slot degraded to
undefined. Use the bounds-checked callFrame->argument(n) for all four
slots instead: it returns undefined for an absent argument and ignores
trailing ones, matching Node's plain-JS signature.
Comment thread src/jsc/bindings/JSBuffer.cpp Resolved
Node's normalizeEncoding tests `enc == null`, so an explicit null
encoding is the same as an absent one and defaults to utf8. Bun's
fill() and Buffer.alloc() gated on isUndefined(), so null reached
parseEncoding and threw:

  Buffer.alloc(5, 0xaa).fill("a", 1, 3, null)  // bun ERR_INVALID_ARG_TYPE
                                               // node <aa 61 61 aa aa>
  Buffer.alloc(3, "a", null)                   // bun ERR_INVALID_ARG_TYPE
                                               // node <61 61 61>

buf.toString(null) is unchanged: Node routes it through getEncodingOps,
which does throw ERR_UNKNOWN_ENCODING for a null encoding, so the null
handling belongs at these two call sites and not inside parseEncoding.
Comment thread src/jsc/bindings/JSBuffer.cpp Resolved
Node's normalizeEncoding also returns utf8 for an empty string (the
slowCases default branch), so the treat-as-absent set is exactly
undefined, null, and "". The fill() and Buffer.alloc() encoding gates
only skipped null and undefined, so "" reached parseEncoding and threw:

  Buffer.alloc(5, 0xaa).fill("a", 1, 3, "")  // bun ERR_UNKNOWN_ENCODING
                                             // node <aa 61 61 aa aa>
  Buffer.alloc(5, 0xaa).fill("a", "")        // bun ERR_UNKNOWN_ENCODING
                                             // node <61 61 61 61 61>
  Buffer.alloc(3, "a", "")                   // bun ERR_UNKNOWN_ENCODING
                                             // node <61 61 61>

Extract the two gates' shared predicate into isAbsentEncoding, which now
covers all three values. parseEncoding is unchanged: buf.toString("")
and buf.toString(null) must keep throwing ERR_UNKNOWN_ENCODING, because
Node routes toString through getEncodingOps, which rejects both.

Buffer.from("ab", "") has the same symptom but through a different Node
helper (fromString treats any non-string or empty-string encoding as
utf8, and Bun already matches it for every non-string value); its gate
is outside this file's fill/alloc path and is left for its own change.
Comment thread src/jsc/bindings/JSBuffer.cpp Resolved

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

All four of my earlier findings are addressed and the 48-case Node/Bun differential is a strong correctness backstop — nothing further from me. Deferring only because this rewrites fill's C++ argument intake in JSC bindings, which is worth a human glance even when the tests are this thorough.

Extended reasoning...

Overview

This PR rewrites the argument intake of jsBufferPrototypeFunction_fillBody in src/jsc/bindings/JSBuffer.cpp to match Node's Buffer.prototype.fill semantics for offset/end/encoding handling. It also adds a small isAbsentEncoding helper (shared with Buffer.alloc's encoding gate), a 48-case differential fixture (test/js/node/buffer-fill-args-fixture.js) that is run under both Node and Bun and required to be byte-identical, and an 11-test describe block in buffer.test.js.

The C++ changes are: drop the zero-argument early return in favor of bounds-checked callFrame->argument(0); replace the fallthrough switch on argumentCount() with three argument(n) reads (the established idiom from writeBody); gate the string-offset/end → encoding reinterpretation on value.isString() and discard end in the offset-slot branch; nest end validation inside the offset-present branch; and route the encoding-presence check through isAbsentEncoding (undefined / null / "").

Security risks

Low. Buffer.prototype.fill is memory-adjacent, but this PR does not touch the bounds enforcement (validateInteger on offset and end), the post-coercion detach/resize clamp, or the actual write loop — only the JS-value classification that feeds those validators. The move from uncheckedArgument to argument is strictly safer. isAbsentEncoding is a pure predicate on a JSValue with no side effects. I see no new path that could widen the written range or bypass the existing offset/end checks.

Level of scrutiny

Medium. This is hand-written C++ in JSC bindings on a hot, user-facing Buffer method. The change is self-contained and conservative, and the differential test is unusually strong evidence of correctness, but rewriting argument-intake control flow in this file is the kind of change a maintainer typically wants to eyeball once — particularly the interaction with the existing detach/resize hardening that the surrounding comments reference.

Other factors

  • The bug-hunting system found nothing on the current revision.
  • I left four inline findings on earlier revisions (0-arg / ≥5-arg handling, null encoding, "" encoding, boxed-String encoding); the first three were folded in with tests, and the fourth was correctly deferred to a follow-up and documented in the PR body's "Deliberately excluded" section, since fixing it requires changing parseEncoding and the TOCTOU-hardening suite together. All four threads are resolved.
  • CodeRabbit's one finding was incorrect (it would have made Bun diverge from Node) and was withdrawn after the author pinned the contract with extra fixture cases.
  • The two CI failures (terminal.test.ts on macOS x64, test-net-connect-memleak.js on Linux) are unrelated flakes — neither touches Buffer.
  • No CODEOWNERS entry covers src/jsc/bindings/JSBuffer.cpp.

Given "when in doubt, do NOT approve" and that this is a non-mechanical C++ change in core runtime code, I'm deferring rather than shadow-approving, even though I have no outstanding concerns.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

CI status, for whoever reviews this: the diff is green, and the three remaining red lanes are unrelated to it.

Build 66658 is a clean retrigger of the full pipeline at the final commit: 281 lanes passed, 3 failed. Every failure is in a file this PR does not touch.

lane failing file why it is unrelated
alpine 3.23 x64 and alpine 3.23 x64-baseline test/js/node/test/parallel/test-net-connect-memleak.js assert.strictEqual(collected, true) after globalThis.gc(), a GC-observability timing check. It failed on both musl lanes and passed on every glibc Linux lane in the same build, so it is musl-specific GC timing. An argument-parsing change cannot retain a net.Socket.
windows 2019 x64-baseline test/cli/init/init.test.ts bun init scaffolding plus a package install, exit 143 (timed out and was killed). The pipeline's own classifier tagged this one flaky and auto-retried it.
windows 2019 x64-baseline test/napi/napi.test.ts The N-API native-addon suite (compiles and bundles .node addons). A Buffer.prototype.fill argument change has no N-API surface.

test/js/node/buffer.test.js (533 tests, including the 11 added here and the Node/Bun differential fixture) has never appeared in a failure annotation on any lane across the six CI builds of this PR, and is green on linux-glibc, linux-musl, macOS, and Windows. The differential fixture is byte-identical between Node v24.3.0 (the version the CI image pins), Node v26.3.0, and Bun with this change.

Review state: CodeRabbit reports no actionable comments on the final revision; the Claude Code Review's closing summary has nothing outstanding and defers only for a human look at the C++; all five review threads are resolved. I will not push further retriggers. This is ready for a maintainer.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this gets the encoding as a string twice, consolidate to return an std::optional<JSString*>?

isAbsentEncoding checked the value with asString() and then parseEncoding
coerced the same value again with toStringOrNull(). Replace it with
resolveEncodingString, which returns std::optional<JSString*>: nullopt
for Node's absent set (undefined, null, the primitive empty string) and
otherwise the encoding resolved to a JSString exactly once. parseEncoding
gains an overload taking that JSString directly, and the existing JSValue
overload (used by toString and write) delegates to it unchanged.

No behavior change: the 48-shape Node/Bun differential, the full
buffer.test.js suite, and the detaching-encoding-toString TOCTOU suite
all pass unchanged.
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Done in b721d06.

isAbsentEncoding(JSValue) -> bool is now resolveEncodingString(scope, globalObject, JSValue) -> std::optional<JSString*>. It returns nullopt for Node's absent set (undefined, null, and the primitive ""), and otherwise the encoding already resolved to a JSString, which both call sites hand straight to a new parseEncoding(JSString*, JSValue, bool) overload. So the value is turned into a string exactly once. The pre-existing parseEncoding(JSValue, bool) became a thin wrapper (toStringOrNull, then delegate), so toString and the two write callers are untouched.

No behavior change: the 48-shape Node/Bun differential fixture is still byte-identical to Node, buffer.test.js (533) passes, and the detaching-encoding-toString TOCTOU suite in buffer-copy-fill-detach.test.ts (23) still passes, which pins that the one remaining toStringOrNull on a non-string encoding still runs at the same point.

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

Nothing outstanding from my side on the final revision — all four threads I raised are addressed and the differential fixture pins them — but this reworks argument intake in the C++ Buffer.fill/Buffer.alloc bindings (with the TOCTOU-sensitive encoding-coercion point moved into a new helper), so a maintainer should sign off.

Extended reasoning...

Overview

The PR rewrites the argument-intake logic of jsBufferPrototypeFunction_fillBody in src/jsc/bindings/JSBuffer.cpp to match Node's _fill semantics exactly: gating the string-offset/end → encoding reinterpretation on value.isString(), nesting end validation inside the offset-present branch, replacing the argument-count switch with bounds-checked callFrame->argument(n), and routing the encoding gate (in both fill and Buffer.alloc) through a new resolveEncodingString helper that treats undefined/null/"" as absent. parseEncoding is split into a JSString* overload plus a thin JSValue wrapper so the encoding is stringified once. Tests add a 48-shape Node-vs-Bun differential fixture and 11 targeted unit tests.

Security risks

Buffer.fill and Buffer.alloc write to raw memory, and the encoding argument's toStringOrNull is a user-JS re-entrancy point that can detach or resize the underlying ArrayBuffer mid-call. The PR moves that call from inside parseEncoding to resolveEncodingString, but at the same control-flow point (before offset/end validation, before the post-coercion byteLength() clamp that bounds the actual write). The existing buffer-copy-fill-detach.test.ts TOCTOU suite is reported passing, and the in-source comments documenting the clamp are preserved. I see no new OOB-write or use-after-detach surface introduced — the offset/end values still flow through validateInteger and the downstream clamp — but this is exactly the kind of change where a second pair of eyes on the C++ is worthwhile.

Level of scrutiny

Medium-high. This is not a mechanical or config change: it restructures control flow in a memory-writing native binding, adds a new helper with a non-trivial contract (std::optional<JSString*> where nullopt means absent-encoding, and the non-primitive path can throw), and the final commit (b721d06) was a refactor landed after the last round of review discussion. The change is narrowly scoped, exhaustively commented, and pinned by a differential fixture against real Node, which substantially de-risks it — but "argument parsing for Buffer.fill" is upstream of memset bounds, so it warrants a maintainer's read rather than a bot approval.

Other factors

All five review threads (one CodeRabbit, four of mine) are resolved; each fold-in landed with fixture coverage and an explicit unit test. The two deliberately-excluded adjacent divergences are documented in the PR body with rationale. CI on the final commit is green on every lane that touches buffer.test.js; the three red lanes are unrelated (musl GC timing, Windows flaky init/napi). No CODEOWNERS entry covers this path. The bug-hunting pass on b721d06 found nothing.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Final CI status for the reviewed commit (b721d06, the std::optional<JSString*> consolidation): build 66954 finished with 283 lanes passed, 3 failed, and all three are the same unrelated items from the breakdown above:

  • darwin 26 aarch64: the buildkite-agent artifact download timed out after 120s infra failure on the same agent, before a single test ran. It has hit 6 of this PR's 7 builds.
  • alpine 3.23 x64 and alpine 3.23 x64-baseline: test/js/node/test/parallel/test-net-connect-memleak.js, the musl-only GC-timing assertion (collected still false after gc()). It passed on every glibc Linux lane again.

The two Windows flakes from the previous build did not recur this time. test/js/node/buffer.test.js has never appeared in a failure annotation on any lane across all seven builds of this PR, and every build-bun step passed, so the refactor compiles cleanly on all platforms.

Nothing here is actionable from the diff, and I have already used one retrigger, so I will not push more. Ready for a maintainer.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

shouldn't it be toString instead of toStringOrNull? sot that it throws the exception

…oding

Both JSC variants throw the pending exception; toStringOrNull only
returns null instead of the empty string on the throwing path (see the
comment on JSValue::toStringOrNull in JSCJSValue.h). With toString, an
engaged std::optional<JSString*> can never hold a null pointer. Add a
test pinning that an exception thrown from an object encoding's
toString propagates out of fill() and Buffer.alloc().
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Done in ba95354: both call sites (resolveEncodingString and the parseEncoding(JSValue) wrapper) use toString now, and I added a test pinning that an exception thrown from an object encoding's toString propagates out of both fill() and Buffer.alloc().

One note for accuracy: the exception was already being thrown. In JSC both variants throw; they differ only in what they return on the throwing path. From JSCJSValue.h:

inline JSString* toString(JSGlobalObject*) const;       // On exception, this returns the empty string.
inline JSString* toStringOrNull(JSGlobalObject*) const; // On exception, this returns null, to make exception checks faster.

They share toStringSlowCase(globalObject, returnEmptyStringOnError), which throws into the VM either way, and the RETURN_IF_EXCEPTION right after the call catches it. Verified before the change:

Buffer.alloc(4).fill("a", 0, 4, { toString() { throw new Error("boom"); } });
// already threw "boom"

toString is still the better choice here, for a different reason: toStringOrNull's nullptr error value flowing into an std::optional<JSString*> produces an engaged optional that holds nullptr, a latent null deref if the exception check ever got reordered, whereas toString's empty-string error value cannot. So I switched both and added the toThrow("boom") assertions for fill and alloc.

No behavior change otherwise: the 48-shape Node differential, the full buffer.test.js suite (534), and the detaching-encoding-toString TOCTOU suite (23) all pass unchanged.

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

All earlier feedback is addressed and the final resolveEncodingString refactor + toString switch look correct, but this is a non-trivial rewrite of fill()'s argument intake in the JSC C++ bindings (with TOCTOU/detachment-adjacent paths), so it warrants a maintainer's sign-off.

Extended reasoning...

Overview

This PR rewrites jsBufferPrototypeFunction_fillBody's argument intake in src/jsc/bindings/JSBuffer.cpp to match Node's Buffer.prototype.fill semantics exactly: gating the string-offset/end → encoding reinterpretation on value.isString(), nesting end validation under the offset-present branch, replacing the argument-count switch and zero-arg early return with bounds-checked callFrame->argument(n), and routing the encoding gate (in both fill and Buffer.alloc) through a new resolveEncodingString helper that treats undefined/null/"" as absent. parseEncoding is split into a JSString* overload plus a JSValue wrapper. Backed by a 48-case Node/Bun differential fixture and 11 new unit tests.

Security risks

No new attack surface. The change is pure argument-validation reshuffling; the actual memset/memmove and post-coercion byte-length clamp are untouched. The detachment/TOCTOU-hardening comments and the buffer-copy-fill-detach.test.ts suite still pass unchanged. The toStringOrNull → toString switch in the final commit avoids a latent engaged-optional-holding-nullptr hazard, which is a hardening, not a regression.

Level of scrutiny

Medium-high. Buffer.prototype.fill is a hot, memory-writing path in the JSC bindings, and the function this PR rewrites carries explicit TOCTOU comments about user-JS re-entrancy via toString. The change is well-scoped and thoroughly tested (differential against real Node on every CI run), but it is not a mechanical or obvious change — it went through eight commits absorbing four review findings, and the final two commits (b721d06, ba95354) introduced a new helper and refactored the encoding-resolution flow after my last inline comment.

Other factors

All five inline review threads (one CodeRabbit, four from me) are resolved; the author verified each fix against Node v24.3.0 and v26.3.0 and pinned them in the fixture. CI is green on every lane that ran tests; the three failing lanes are unrelated (musl GC timing, darwin artifact-download infra, Windows flakes). The bug-hunting system found nothing on the final revision. Two adjacent pre-existing divergences (Buffer.from("ab", "") and boxed-String encodings) are deliberately deferred and documented in the PR body. No CODEOWNERS coverage on the touched files. Given the C++ surface and the iterative review history, a maintainer should sign off rather than a bot.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI for ba95354 (the toString change): build 67024 finished with 283 lanes passed, 3 failed, all unrelated to the diff.

  • alpine 3.23 x64 and alpine 3.23 x64-baseline: test/js/node/test/parallel/test-net-connect-memleak.js, the same musl-only GC-observability assertion as the previous builds (it checks that a net once('connect') listener closure got collected after gc()). It passed on every glibc Linux lane in this build, as it has in every build. An argument-parsing change cannot make a socket listener un-collectable, and the glibc vs musl split rules the diff out as a cause.
  • darwin 14 aarch64: test/regression/issue/20965.test.ts, an HTTP streaming-file-response abort test (server.stop() after an aborted 5 MB transfer), timed out at 90s on that one mac lane. It exercises Bun.serve's pending_requests accounting; its only Buffer use is a one-argument Buffer.alloc(n), a path this PR does not touch (the alloc change is inside the argumentCount() > 1 branch). It passed on this same lane in every previous build of this PR and on the other 283 lanes here.

The darwin 26 aarch64 artifact-download lane that failed the earlier builds passed this time. test/js/node/buffer.test.js has never appeared in a failure annotation on any lane across all ten builds of this PR. Nothing here is actionable from the diff, and I have already used my one retrigger, so I will not push more.

@Jarred-Sumner
Jarred-Sumner merged commit c944094 into main Jun 30, 2026
75 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/ba6603b8/buffer-fill-arg-reinterpretation branch June 30, 2026 02:50
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.

2 participants