Skip to content

bindgen: delete b.any and .variadic - #46789

Open
dylan-conway wants to merge 65 commits into
mainfrom
claude/bindgen-no-any
Open

dylan-conway wants to merge 65 commits into
mainfrom
claude/bindgen-no-any

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Deletes b.any and .variadic from bindgen. A schema now says one of two things about every argument, member and result: what the generated code converts it to, or that it is handed over as it is.

In the schemas main this PR
b.any 715 in 95 files 0
.variadic 16 in 9 files 0
b.opaque 489 in 87 files
b.rest(..) 2 12
b.union(..) 42 89
b.dictionary(..) 78 129, of which 25 are deferred

The schemas of the generator's own tests are not counted.

What each b.any became

On main This PR
b.any that the body converted by hand: a string to text or bytes, a number to an integer, an options object to fields, an instance to its native pointer the type, and the hand-written conversion is deleted
b.any that the body stores, hands on, returns, or tests itself (is_number(), as_array_buffer()) b.opaque, and the body keeps main's text
b.any whose conversion JavaScript can observe, so it has to run at a point the body picks T.deferred, or b.dictionary(members, { deferred: true }) for an options object
b.any.variadic b.rest(b.opaque)

The rules the schemas follow:

  • A union is for a union. It is used where at least one alternative is converted and the alternatives are handled differently. An argument whose body only looks at the raw value is b.opaque.
  • An options object is typed whole, or not at all. It is a dictionary with all of its members, typed where JavaScript calls in, or it is b.opaque with main's text. No dictionary is built below the entry from a stored value.
  • Generated types stop at the signature of the function JavaScript calls. Below it, src/jsc and the helpers keep their JSValue signatures. A parser that turns the typed argument into the tree's own type (Arguments::from_js(options), Value::from_js(BodyInit)) takes the generated type where that is shorter than main's text.
  • Names. A Rust function is the snake_case of its schema name. In a union b.undefined is missing, b.null is none, and a last b.ignored or b.opaque is other.

What bindgen gained

docs/project/bindgen.mdx describes each.

  • Types: b.opaque, b.ignored, b.cell(..) as the type of an argument, b.wrapped(..) with .shared, b.promise, b.dataView, b.abortSignal, b.stringObject, b.stringLike, b.bigInt, b.jsNumber, b.i64.
  • Modifiers: .deferred, .validateIntegerRange(), .nanAs(), b.i64.asIntN.
  • Options: deferred: true on a dictionary, fromJS on a union, reportedAs on a member, own, .ifPassed and privateSymbol on a member of a deferred dictionary, a union as an alternative of a union.
  • A conversion the body calls answers in Rust what Rust can tell. Each generated convert_* first tests the bits of the value or the type byte of its cell: undefined for a type with a default, a boolean, a number, a safe integer in range, a string, a BigInt, an array, a function that is not a proxy. It calls C++ for the rest.

One change outside the bindings: wait_for_promise returns Stopped when a termination is pending, even if the promise settled in the tick that raised it. A conversion asserts if it runs with a termination pending.

Behaviour

Only what differs from main is listed.

Wrong on main, right here (3). Each test fails on main.

Call main This PR Test
Bun.build({ naming: "[name].js" }) reads naming twice reads it once bun-build-api.test.ts: "a string naming is read once"
fs.promises.write(fd, new String("a")) runs the toString of the object, then refuses it refuses it and runs nothing fs.test.ts: "promises.write refuses a String object without converting it"
init(.., new Proxy(fn, {})) of a native zlib, brotli or zstd handle refuses the callback takes it zlib-handle-bounds-check.test.ts: "init takes a callable Proxy as the write callback"

Right on main, right here, reads differently. The class of the error and its code are equal in every case. 86 wordings differ; these are the groups, each with one example.

Where main This PR
a member of the options of a node:fs function, name of Bun.Terminal The "bigint" property must be of type boolean, got string The "options.bigint" property must be of type boolean. Received type string ('a'), Node's form
position of fs.read and fs.write must be of type number or bigint must be of type bigint or integer, Node's wording
signal of node:fs, Bun.spawn, an HTTP/2 request must be of type AbortSignal must be an instance of AbortSignal, Node's wording
a member of the options of an HTTP/2 request, rmdir, SocketAddress The "options.silent" argument .. The "options.silent" property .., Node's wording
a member of the options of Bun.build The "target" property must be of type string, got number target must be a string
a handler of the native HTTP/2 parser Expected "error" callback to be a function handlers.error must be a function
onExit, onDisconnect, timeout, cgroup of Bun.spawn onExit must be a function or undefined onExit must be a function
server.requestIP(1), server.subscriberCount(1) Expected Request object request must be an instance of Request
an argument that is left out: chown, futimes, appendFile, fs.promises.read and write uid is required The "uid" argument must be of type number. Received undefined, Node's wording
buffer of fs.promises.write, dictionary of a brotli handle must be of type string or TypedArray must be of type string or an instance of Buffer, TypedArray, or DataView, Node's wording
altsvc(origin, value) of the native HTTP/2 parser must be of type origin must be of type string
flags and fd of Bun.udpSocket, mode of chmod The "flags" property must be of type number, got string The "flags" argument must be of type number. Received type string ('a')
sourcemap of the build options of Bun.serve({ app }) The "sourcemap" property must be of type "inline" | "external" | "linked", got number sourcemap must be a string
fs.readvSync(fd, 1) Expected an ArrayBufferView[] Expected ArrayBufferView[]
socket.setKeepAlive(true, -1) It must be >= 0 and <= 9007199254740991 It must be >= 0 and <= 2147483647, the bound both enforce
an fd, mode or flags above 2**32 Received 8589934592 Received 8_589_934_592: Node's wording for mode and flags, and Node prints an fd without the separators

A refused object has its constructor read once, for Received an instance of .. in the message. A Proxy sees that read.

Right on main, wrong here: 0. Wrong on main, wrong in another way here: 0.

Cost of a call

Counted by reading the generated code and the bodies of main and of this PR: calls from Rust into C++, property reads, allocations and copies on a call that succeeds.

  • Fewer calls into C++: readSync; statSync, lstatSync and mkdirSync with options (2 to 1); existsSync, unlinkSync and renameSync with a URL; writeSync of a string in an encoding other than UTF-8; closeSync, fsyncSync; Bun.write to a Blob; new Blob(parts, { type }) (2 to 1); a body made from FormData, URLSearchParams or a Blob; ws.send, sendText, ping, pong and publish* with a string (2 to 1); server.publish; fetch(string); an HTTP/2 request; setKeepAlive, setNoDelay; the options of Bun.udpSocket.
  • More: none. No struct grows. args::Write loses a field and so does Payload::Bytes.

Binary size

The stripped release bun for Linux x64 is 138,390,072 bytes, 106,496 more than main (138,283,576). The growth is the conversions themselves: main converted these arguments with shared helpers that take a JSValue, and the generated code has one conversion for each typed argument and member, with its name and its error text.

By symbols, against main Bytes
C++ functions of the conversions that a body calls (.deferred, the members of a deferred dictionary) +44,940
node:fs +31,693
data: names of members, error text, a row for each parameter in the --aot table +28,816
Bun.build +8,417
host functions and thunks -17,820

--aot

621 members have an entry that compiled code calls directly; main has 815. A union has no direct form, so a member with a union argument is called through its host function. The cost is not measured.

How did you verify your code works?

  • test/internal/bindgen.test.ts covers each new type, modifier and refusal. It also converts a list of values with and without .deferred and compares the results.
  • Probes against main. 34 scripts call the changed functions with a fixed list of values in each argument and each member, and log results, errors, and the order of getters, toString, valueOf and Proxy traps. Release build against release build: 314,731 lines, of which 8,451 differ in the message only and 36 in the constructor read. A script sorts every differing line into "message only", "constructor read on a refused value" and "other", and every "other" line was read: clock, port, working directory, temporary names, and races of calls that are not awaited. The tables above are that output.
  • The tests of the changed areas, the tests that hold the schemas against the --aot table of built-ins, the source lints, and bun-types.test.ts.

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 6:05 AM PT - Oct 29th, 2026

❌ @dylan-conway, your commit b2011a2 has 1 failures in Build #135060 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46789

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

bun-46789 --bun

}
let error_code = code.to_int32();
};
let error_code = code as i32;

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.

🟣 pre-existing, not blocking: node:http2 users calling session.goaway with an error code of 2**31 or more now send 0x7FFFFFFF on the wire instead of the code they passed. src/runtime/api/bun/h2_frame_parser.rs:4684 does code as i32, which saturates, and then ErrorCode(error_code as u32) at :4706 and :4714. The base used to_int32() (modular ToInt32), so as u32 restored the exact uint32. Fix: convert the f64 straight to u32 (code as u32), as rst_stream at :4943 and emit_error_to_all_streams at :6268 already do, so every code in the 0..=4294967295 range that http2.ts validates reaches the GOAWAY frame unchanged.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Http2Session.prototype.goaway(code) in src/js/node/http2.ts:4691 validates code with validateInteger(code, "code", 0, kMaxUint32) and passes it to the parser; the client-side goaway at src/js/node/http2.ts:5628 passes it through too. In src/runtime/api/bun/h2_frame_parser.rs:4684 the new body does let error_code = code as i32;, which saturates any f64 >= 2147483648 to 2147483647. ErrorCode(error_code as u32) at :4706 and :4714 therefore puts 0x7FFFFFFF into the GOAWAY frame. On the base branch the same line was code.to_int32() (ECMAScript ToInt32, modular), so 4294967295 became -1 and as u32 gave back 4294967295, the exact code the caller passed. The peer now receives a different error code than the application sent for any code in 2147483648..=4294967295, which the JS validation explicitly allows. The sibling conversions code as u32 at :4943 (rst_stream) and :6268 (emit_error_to_all_streams) do not have this problem.

Verification: pre-existing — triggered when session.goaway(code) is called with code >= 2**31. src/runtime/api/bun/h2_frame_parser.rs:4684 let error_code = code as i32; saturates to i32::MAX, and :4706/:4714 put 0x7FFFFFFF into the GOAWAY frame. The base did NOT preserve the value: base JSValue::to_int32 (179664e:src/jsc/JSValue.rs:694-707) also saturates, so base sent the same wrong code.

…as before, and the arguments a body tested by class have their types
…into claude/bindgen-no-any

# Conflicts:
#	src/runtime/api/JSSourceMap.rs
#	src/runtime/api/YAMLObject.rs
… takes `undefined` and that an array argument is only read

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

Code review found no new issues

No new issues were found in this update; 2 findings from earlier reviews are still open above.

Still open from earlier reviews (2):

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

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

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

},
{ fromJS: true },
);
const id = b.i32.validateIntegerRange(-2147483648, 2147483647).nanAs(0).nullable;

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.

🟣 pre-existing, not blocking: pre-existing, security-relevant: a root process calling Bun.spawn({ uid: NaN }) or { gid: NaN } spawns the child as uid/gid 0 instead of throwing. The schema at src/runtime/api/bun/js_bun_spawn_bindings.bind.ts:27 maps NaN to 0 with .nanAs(0), and js_bun_spawn_bindings.rs:611-617 passes that 0 to the OS. A NaN here is almost always a bug in the caller (parseInt of an unset env var), so the privilege drop silently does not happen. Fix: reject NaN for uid and gid (drop .nanAs(0) so validateIntegerRange reports it as ERR_INVALID_ARG_TYPE, or throw ERR_OUT_OF_RANGE as Node's validateInteger does), keeping null/undefined as "not set".
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A process running as root calls Bun.spawn({ cmd, uid: Number(process.env.RUN_AS) }) with the variable unset, so uid is NaN. The member type at src/runtime/api/bun/js_bun_spawn_bindings.bind.ts:27 is b.i32.validateIntegerRange(-2147483648, 2147483647).nanAs(0).nullable, used for uid and gid at :57-58. In C++, src/jsc/bindings/Bindgen/IDLConvertIntegerRange.cpp:24-25 returns the nan value 0 before the integer and range checks. src/runtime/api/bun/js_bun_spawn_bindings.rs:611-617 then sets uid = Some(0) and the child is spawned as root, with no error. Node's validateInteger throws ERR_OUT_OF_RANGE ("an integer") for NaN, and Bun's own node:child_process guards its path with isInt32 at src/js/node/child_process.ts:989, so only direct Bun.spawn callers are affected. The base branch behaves the same: src/jsc/JSGlobalObject.rs:1193-1196 on 179664e returned the default 0 for NaN, so this is pre-existing, but the diff re-encodes it explicitly in the schema. A non-root caller gets EPERM from setuid(0) instead, which hides the real cause.

Verification: pre-existing (security-relevant, does not block). Trigger: a process calls Bun.spawn directly with uid or gid equal to NaN. src/runtime/api/bun/js_bun_spawn_bindings.bind.ts:27 uses .nanAs(0), so the OS receives uid/gid 0 and a root parent spawns a child that still runs as root instead of throwing. Base branch fails by the same route: src/jsc/JSGlobalObject.rs:1193-1196 returns Ok(default) on NaN.

…functions, and the commands of `RedisClient` and the paths of `node:fs` are `.deferred`
…f the promise settled in the tick that raised it

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

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

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

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment thread src/runtime/api/bun/Terminal.bind.ts Resolved
@robobun

robobun commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

A question about order, from #48124 (stacked on #47993). Both change options.signal of readFile, writeFile and appendFile in the two parse blocks that this PR replaces with the schema.

If this PR lands first, I redo both on it. The shared signal member then needs an arm for a non-native object for the three file operations, and WatchOptions stays native-only. If they land first, this PR gets two more files with conflicts (node_fs_binding.rs and util/validators.rs).

Which order do you prefer?

@robobun

robobun commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator

A question about order, from #43563.

#43563 makes readFile, writeFile, appendFile, readdir, readlink, realpath and mkdtemp read their options before the path, as Node's getOptions() runs before getValidatedPath(). With an invalid path and an invalid encoding, Node reports the encoding and Bun reports the path. At the head of this PR the order is still path first: Readlink::from_js calls PathLike::from_js_required before parse_encoding_arg.

If this PR lands first, I redo #43563 on it. The path rows are then one statement in each parser: convert the options before the path. Two lines of the schema decide the rest.

  • encodingOr takes b.stringLike (node_fs_binding.bind.ts:52). Node takes an encoding name from a primitive string only. There a String object is an options object, and a function means the defaults.
  • The fd range of PathOrFileDescriptor (:22) is checked before the parser body runs. Node reports an invalid encoding before an invalid fd in readFile, writeFile and appendFile.

If #43563 lands first, this PR gets conflicts in the seven parser heads of node_fs.rs, and the new ArgumentsSlice::peek_at goes away with ArgumentsSlice.

Which order do you prefer?

This branch has not been deployed

No deployments
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