Skip to content

fs.promises: reject a non-number offset in FileHandle.write before the default length uses it - #46220

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/d3ee6532/filehandle-write-offset-check
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/d3ee6532/filehandle-write-offset-check

Conversation

@robobun

@robobun robobun commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • filehandle.write(buf, 1n) rejects with a TypeError that has no code ("Invalid mix of BigInt and other type in subtraction."). Node rejects with ERR_INVALID_ARG_TYPE. An object offset has its valueOf called first, and a function offset writes the whole view.
  • The cause is src/js/node/fs.promises.ts:650: length = buffer.byteLength - offset runs before any check of offset.
  • A differential fuzz run against Node found it. No user reported it.

Fix

Background

Downsides

Notes

Questions for a maintainer

  • .claude/docs/landing-prs.md says to use the shared validators and no hand-rolled typeof checks. Node spells this statement validateInteger(offset, 'offset', 0), as FileHandle.read does at fs.promises.ts:553. This PR uses typeof with $ERR_INVALID_ARG_TYPE, so a valid write makes no call into C++ and the integer check runs once, in args::Write::from_js. If you want Node's spelling, the arm becomes else { validateInteger(offset, "offset", 0); }.
  • return await Promise.$reject(...) is not used anywhere else in src/js. A plain throw is simpler, but it rejects one microtask turn before Node and before main: 353 rows of the matrix below equal Node in every field on main and would then differ in the turn. With the awaited form that count is 0. The test a refused offset rejects one turn after the call, as in node holds the turn. If you prefer the throw, that test goes with it.

Repro (Node v26.3.0 against main). With this PR every row equals the Node column.

fh.write(buf, ...)                         | node v26.3.0                  | main
(1n)                                       | ERR_INVALID_ARG_TYPE          | TypeError, no code
(Symbol())                                 | ERR_INVALID_ARG_TYPE          | TypeError, no code
({ offset: 1n, length: null })             | ERR_INVALID_ARG_TYPE          | TypeError, no code
({ offset: { valueOf }, length: null })    | ERR_INVALID_ARG_TYPE, 0 calls | ERR_INVALID_ARG_TYPE, 1 call
(fn)                                       | ERR_INVALID_ARG_TYPE          | writes 4 bytes
(fn, 1, 0)                                 | ERR_INVALID_ARG_TYPE          | writes 4 bytes

Reach. The bug needs an offset that is not a number. No valid call changes. Searches of the tracker for filehandle.write offset and "Invalid mix of BigInt" find no report of this symptom. The vendored test-fs-promises-write-optional-params.js covers only { offset: false } and { offset: true }, which main already rejects, so this PR turns on no new Node test.

Rows that still differ from Node, or equal it in a way that looks wrong

  • { offset: 1n } with no length is a bare TypeError in Node too: its destructuring default does the same subtraction. Unchanged, and tested.
  • An out-of-range offset (-1, 1.5, NaN, 2**53) is still ERR_OUT_OF_RANGE from the native parser. For 2**53 the message has no digit grouping (9007199254740992, Node prints 9_007_199_254_740_992). Unchanged.
  • (view, 0, NaN) writes 0 bytes, Node throws ERR_OUT_OF_RANGE. fs: read offset, length and position as independent slots in write and writeSync #45837 covers the length slot.

Matrix (54 offset kinds x 20 argument forms on a Buffer, plus 4 other view kinds: 1,333 rows, Node v26.3.0 against main and this PR)

main this PR
rows equal to Node on code, class, hook calls and file bytes 1,004 1,328
rows where Bun writes and Node rejects 213 5 (the NaN length rows)
rows where the count of valueOf or Symbol.toPrimitive calls differs 60 0
rows that settle on Node's microtask turn 998 1,302
rows equal to Node in every recorded field (message and turn too) 814 1,118

Regressions: 0. No row that equals Node on main changes in any recorded field.

Overlap with #45837 (a deduction from the matrix and from that PR's diff. No build of #45837 was run.)

Self-review concerns not taken

Cost measurements (main against this PR, production bundle from bun scripts/build.ts --profile=release --mode=codegen)

  • Executed bytecodes before the await, main to PR: (buf, 0, 4, 0) 72 to 76, (buf) 84 to 85, (buf, 1) 82 to 86, (buf, {}) 98 to 102 (BUN_JSC_dumpGeneratedBytecodes=1). The new instructions on a valid path are resolve_scope, get_from_scope, is_number and jtrue, or one jmp when no offset is passed.
  • FileHandle.write body: 174 to 196 bytecode instructions, 1,013 to 1,118 bytes. The generator frame keeps 2 slots and 22 callee registers. The async wrapper stays at 43 instructions.
  • validateInteger host calls per 100 fh.write calls: 0 (lldb breakpoint count over 5 valid shapes. The same count for fh.read(buf, 0, 4, 0) is 100).
  • JS cells per fh.write(buf, 0, 4, 0): the same on main and here (Promise 3, JSLexicalEnvironment 2, Function 1, Object 1, Array 1, AsyncFunctionGenerator 1, from bun:jsc heapStats over 2,000 calls). A refused write allocates one more Promise than a throw would.
  • Bundled fs.promises.js: 43,573 to 43,712 bytes. The module blob that release builds link (InternalModuleRegistryConstants.bin): 2,710,570 to 2,710,709 bytes.
  • Not measured: machine instructions and syscall counts. perf, valgrind and strace are not installed in the build container. The bytecode counts come from the production bundles, loaded by the debug binary. No release binary was built.

Related PRs

Suites run with the debug build

  • test/js/node/fs/fs-write-offset-bound.test.ts (30 runs in a row), test/js/node/fs/promises.test.js.
  • test/js/node/fs/fs.test.ts: 6 to 7 tests time out per run in this container, on main and on this branch, a different set each run (readdir ... x 100, rmdir, createReadStream, callback fs.write). Each passes alone with -t, and none calls FileHandle.write.
  • The vendored test-fs-promises-write-optional-params.js, test-fs-promises-file-handle-write.js, test-fs-write-optional-params.js, test-fs-promises-file-handle-read.js, test-fs-write-buffer.js, test-fs-promises.js and test-fs-promises-file-handle-writer.js.
  • Not tested: macOS, Windows. The only source change is platform-independent JS.

…e default length uses it

FileHandle.write(view, offset) computed the default length,
view.byteLength - offset, before anything had checked offset. A bigint
or a symbol made the subtraction throw a TypeError with no code, an
object had its valueOf() called before the native write refused it, and
a function was taken for a callback by the native parser, so the whole
view was written.

Check the type of offset where Node calls validateInteger(), before the
subtraction. The native write still checks the integer and its range.
The rejection is awaited, so it settles on the same turn as the native
check and as Node's.
@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed in favour of #47036, which covers this fix. All 14 tests of this PR's test file pass unchanged on a build of #47036 (6cdda02).

How it was reproduced (Node v26.3.0 against Bun 1.4.3 and main):

import { open } from "node:fs/promises";

const fh = await open("/tmp/fh-write-offset.txt", "w+");
const buf = Buffer.from("abcd");

// Node: ERR_INVALID_ARG_TYPE. Bun: undefined ("Invalid mix of BigInt and other type in subtraction.")
await fh.write(buf, 1n).catch(e => console.log(e.code));

// Node: 0. Bun: 1
let calls = 0;
const offset = { valueOf: () => (calls++, 1) };
await fh.write(buf, { offset, length: null }).catch(() => {});
console.log(calls);

// Node: ERR_INVALID_ARG_TYPE. Bun: 4 (the whole view is written)
await fh.write(buf, () => {}).then(r => console.log(r.bytesWritten), e => console.log(e.code));

await fh.close();

On a build of #47036 the three lines print what Node prints.

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:20 PM PT - Oct 20th, 2026

✅ @robobun, your commit 80d79ebc625cf213501b8d95b0cb807f9e1825cd passed in Build #129597! 🎉


🧪   To try this PR locally:

bunx bun-pr 46220

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

bun-46220 --bun

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: c6678934-2e91-414d-98f1-d8c95522acdf
📥 Commits

Reviewing files that changed from the base of the PR and between f180f65 and 80d79eb.

📒 Files selected for processing: 1
  • test/js/node/fs/fs-write-offset-bound.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

FileHandle.write now asynchronously rejects non-numeric buffer-write offsets with ERR_INVALID_ARG_TYPE. Numeric offsets continue through native integer and range validation. Tests cover validation order, write results, and file-handle behavior.

Changes

Offset validation

Layer / File(s) Summary
Offset check and write validation
src/js/node/fs.promises.ts, test/js/node/fs/fs-write-offset-bound.test.ts
FileHandle.write rejects non-numeric buffer-write offsets with ERR_INVALID_ARG_TYPE. Tests cover rejection timing, validation precedence, valid and out-of-range offsets, file contents, and handle cleanup.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 80d79

No actionable merge-blocking risk is established by the reviewed changes.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the primary change: rejecting non-number offsets in FileHandle.write before default length calculation.
Description check Passed The description is detailed and covers the problem, fix, validation, tests, compatibility behavior, performance impact, limitations, and related work. It does not use the exact template headings, but …
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.
Full details: Description check

Explanation

The description is detailed and covers the problem, fix, validation, tests, compatibility behavior, performance impact, limitations, and related work. It does not use the exact template headings, but it provides the required information under equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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 blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked two things that look wrong but are not: { offset: 1n } with no length still rejecting with a code-less TypeError is what Node does too (its options destructure default performs the same subtraction before validateInteger runs), and the test file's fs.mkdtempSync instead of the harness tempDir is required because the file runs under node:test on real Node and cannot import harness, as its existing header already does.

Extended reasoning...

The change adds a three-line typeof offset !== "number" arm in FileHandle.write in src/js/node/fs.promises.ts and seven tests in test/js/node/fs/fs-write-offset-bound.test.ts; it touches no security-sensitive surface. Inline findings cover the rejection-timing inconsistency with the sibling throw sites and the hand-rolled check versus validateInteger, so a human should weigh those; the note above records the two candidate issues examined and ruled out.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/js/node/fs.promises.ts — Callers of FileHandle.write get rejection timing that depends on which invalid argument they passed. The new arm at src/js/node/fs.promises.ts:651 defers the rejection one microtask with return await Promise.$reject, while the sibling exits at line 659 and line 661 still throw and reject on the same turn. Node routes every one of these through fsCall()'s await, so all reject one turn later. Fix: give every validation exit in this method the same delivery timing as Node (await the rejection at lines 659 and 661 too, or validate in one place before a single awaited exit), rather than only the new arm.

    Why this was flagged

    Trigger: a user passes a non-string, non-view buffer (e.g. fh.write(123)) or a bad encoding (fh.write("ab", null, "bogus")) through src/js/node/fs.promises.ts:635. Line 659 throws $ERR_INVALID_ARG_TYPE synchronously inside the async function, and line 661 validateEncoding throws likewise, so the returned promise is already rejected when write() returns. The new arm at line 651 instead awaits Promise.$reject, so a bad offset rejects one turn later. The PR's test at test/js/node/fs/fs-write-offset-bound.test.ts:195-203 pins the delayed turn as Node's behaviour, which shows the author considers timing part of the contract; the untouched sibling exits violate that same contract. The base branch already had the line 659 timing, but the PR introduces the disagreement between exit sites of one method. The 'covered' nit at line 651 is about the one-off return await Promise.$reject form, not about lines 659/661 being on the wrong turn, so a fix to the nit (switching to throw) would not fix this and vice versa.

    Verification: Pre-existing on the two untouched exits. Trigger: fh.write(123) or a bad encoding, observing settlement order. The new arm at src/js/node/fs.promises.ts:651 does return await Promise.$reject(...), so it rejects one microtask later. The sibling exits at line 659 and line 661 throw synchronously, rejecting at tick 0, unchanged from base, while Node surfaces all one tick later through fsCall's await.

Comment on lines +649 to +651
} else if (typeof offset !== "number") {
// Type half of Node's validateInteger(). Awaited to reject on the turn of the native integer and range check.
return await Promise.$reject($ERR_INVALID_ARG_TYPE("offset", "number", offset));

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.

🟡 nit (optional): Maintainers get a hand-rolled offset type check in FileHandle.write that differs from the sibling FileHandle.read, which calls validateInteger(offset, "offset", 0) at src/js/node/fs.promises.ts:553. The new arm at src/js/node/fs.promises.ts:649-651 spells the type half of the validator with typeof and a return await Promise.$reject(...) shape used nowhere else in src/js. Fix: use the shared validator in the same shape as the read sibling (else { validateInteger(offset, "offset", 0); }), or keep the typeof form and align read to it, so both entry points share one error path; the timing test at test/js/node/fs/fs-write-offset-bound.test.ts:148 would go with the validator form.

Why this was flagged

A caller passes a non-number offset to FileHandle.write, e.g. fh.write(buf, 1n), and reaches the new arm at src/js/node/fs.promises.ts:649. The arm rejects with $ERR_INVALID_ARG_TYPE via return await Promise.$reject(...) instead of calling validateInteger as FileHandle.read does at src/js/node/fs.promises.ts:553 for the identical input. Behaviour for users is the Node code and message, so there is no user-visible defect over the base, which threw a code-less TypeError. The cost is a divergent error-path convention between two sibling methods in one file; the PR description itself raises this as an open question and the repository guidance asks for shared validators over typeof checks. This is a style nit, not a correctness bug.

Verification: nit. The new arm at /home/claude/bun/src/js/node/fs.promises.ts:649-651 is a hand-rolled type check that re-spells only the type half of validateInteger, which the sibling FileHandle.read uses for the identical slot at line 550-554. Triggering condition: any non-number offset to FileHandle.write. User-visible behaviour is correct, so nothing breaks; the cost is maintainer-facing divergence from the file's own convention.

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.

Not changed. The choice is a question for a maintainer, and the PR description lists it (Notes, "Questions for a maintainer"). The trade-offs:

  • validateInteger(offset, "offset", 0) is Node's statement and the same as FileHandle.read. It adds one JS-to-C++ call to each write that passes an offset, and the native binding then checks the integer a second time. It also rejects one microtask turn before Node, for type errors and for range errors.
  • typeof with $ERR_INVALID_ARG_TYPE is the shape of the buffer check of this same method. A valid write makes no new call.
  • The awaited rejection keeps the turn that the native check has on main. With a plain throw, 353 of the 1,333 probed argument combinations that equal Node in every field on main reject one turn early.

Each alternative is a one-line change, and the rejection rows of the test pass with each. The turn test goes with either.

FileHandle.read stays on validateInteger. The C++ validator prints Node's digit grouping for a large out-of-range offset, and the native one does not, so the typeof form there would lose that text.

…e.write

The rows for an out-of-range offset asserted the name and the code only.
Assert Node's message for each row where Bun prints the same text. The
2 ** 53 row keeps name and code: Node groups the digits of the received
value and Bun does not. The buffer row asserts the whole message, and
the closed-handle row asserts the syscall.
@robobun

robobun commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

On the timing of the two other validation exits of FileHandle.write (the buffer check and validateEncoding): this PR does not change them.

  • On main they reject one microtask turn before Node. The offset check of FileHandle.read and the prologue checks of the other FileHandle methods do the same.
  • The awaited form on the new arm has a narrower goal. On main the native check refuses a string or a boolean offset on Node's turn. The new arm runs before that check and keeps its turn, so no argument combination that equals Node on main changes in any field.
  • A move of the two other exits to Node's turn is a behaviour change on calls that this PR does not otherwise touch. Node gets the turn from fsCall(), which awaits the inner function. A wrapper of that shape corrects every exit of every method at one place. That is a separate change, and fs.promises: keep a FileHandle's descriptor open while an operation on it is pending #44923 already reworks this prologue.

The two test suggestions are in 80d79eb.

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

The follow-up commit addresses the message assertions, and I found no bugs in the fix itself; a maintainer still needs to settle the open spelling question (typeof + Promise.$reject versus validateInteger as in FileHandle.read), which is a design call rather than a correctness one.

What was reviewed:

  • Checked the one-turn timing claim in the comment at src/js/node/fs.promises.ts:650: write is asyncWrap(fs.write), an async wrapper, so a synchronous native throw becomes an already-rejected promise that await surfaces one turn later; return await Promise.$reject(...) lands on the same turn, so the test at test/js/node/fs/fs-write-offset-bound.test.ts:148 measures a real property.
  • Confirmed the new arm sits outside the try { this[kRef]() ... } block, so a refused write never refs the handle.
  • Confirmed the check uses only typeof and the $ERR_*/Promise.$reject intrinsics, with no user-overridable machinery on the path.
Extended reasoning...

The change adds a three-line type check to FileHandle.prototype.write in src/js/node/fs.promises.ts and appends seven node:test-style tests to an existing test file; the follow-up commit 80d79eb only tightens test assertions. It touches no security-sensitive surface, only the error contract for a non-number offset. The code is small and I verified the microtask-timing rationale against asyncWrap, but the PR description itself asks a maintainer to choose between the typeof spelling and the shared validateInteger used by the read sibling, and that unresolved design choice plus the novel return await Promise.$reject shape in src/js is why a human look is still worthwhile. Sibling entry points (fs.writeSync, fs.promises.write) are explicitly excluded in favor of #45837.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #47036 covers this. It removes the JS length default from FileHandle.write, so the native parser refuses a bigint, a symbol and a function offset with ERR_INVALID_ARG_TYPE, and no valueOf runs. All 14 tests of this PR's test file pass unchanged on a build of #47036 (6cdda02). One case is worse there than on main, a defaulted length on a view of 2 GiB or more, and it is reported in #47036 (comment).

@robobun robobun closed this Oct 27, 2026
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