Stacked on #3223 (the first handle member table); the diff against fix/node-typed-fallback is this PR.
The problem
A native handle seen through no type is a different, smaller object than the same handle seen through its own class.
import * as dgram from "node:dgram";
function tune(socket: any) { socket.setRecvBufferSize(8192); }
const socket = dgram.createSocket("udp4");
socket.bind(0, "127.0.0.1", () => {
socket.setRecvBufferSize(8192); // compiles, works
tune(socket); // builds, then throws: socket.setRecvBufferSize is not a function
});The same from a JavaScript source, where a function parameter has no type: 'dgram.Socket.prototype.setRecvBufferSize' on a dynamic value is not supported yet. The typed lowering calls the member's builtin directly; a boxed handle's members come from the runtime unit's own dispatch, written by hand per unit, which for dgram answers the listener registrations, send, close, bind, connect, disconnect, address, ref and unref, and names the rest in a list of refusals.
What changes
A member in a handle member table (#3223: one row per member, naming the builtin that implements it) is now served on a dynamic receiver through that same builtin.
frontend/lowering/handle-member-arms.ts is a pass over the finished module. In a program that creates a handle of a table's class, a call that names a table member on a dynamic value goes through a helper:
%handle.member.call.setRecvBufferSize.1(receiver, callee, argument0, calleeName):
if callee is undefined && receiver is a dgramSocket handle && argument0 is a number:
dgram.setRecvBufferSize(unbox(receiver), number(argument0)); return undefined
return the call as it was- Both call shapes the frontend produces are covered: a JavaScript method call (
dynInvoke), and "read the member, call it" from a TypeScriptanyreceiver (dynCall), where the helper also requires the member read to have answered undefined, so a member the handle's dynamic view does carry keeps its own function. - The argument tests are the builtin's parameter types from its signature row; the result is what Node returns (the value, or
undefinedfor avoidbuiltin). An omitted optional parameter follows the typed lowering. - A receiver that is not the handle, an argument of another type, and an argument count the member does not take fall through to the call as it was, with the same error as before.
One IR addition makes the test possible without a throw: dynTest gains test: "handle" with handleKind, true exactly for a boxed native handle of that kind. It is emitted inline (a kind compare, then a tag compare); no runtime function is added and no runtime unit changes.
What other programs pay
Nothing. The pass returns at once unless lowering created a handle of a table's class (a journaled flag, set where dgram.createSocket lowers), and adds a helper only for a member name the program actually calls on a dynamic value. No existing program differs in the gate: none of the 5,288 compared programs both creates a dgram socket and calls a socket option on an untyped value (such a call threw before). The two new programs are the ones that use it.
Proof
Sandboxes: Linux x64, Intel Xeon 2.90 GHz, Node 24.22.1.
- Differential:
tests/corpus/compat-dgram-dynamic-receiver.ts(the socket throughanyandunknown) and.mjs(the socket as a function parameter) exercise all fourteen table members, theEBADFof an option on an unbound socket, and theTypeErrorof a value that is not a socket. Both match Node 24.22.1 in stdout, stderr and exit status, plain and withSCRIPTC_SAN=1(reference-count audit included). Every other dgram program passes both lanes (27 plain cases withtests/harness/dgram.test.ts, 18 sanitized).node scripts/checker-identity.mjs --filter compat-: 38 programs identical with tsgo and tsc-ts, release and dev. - Zero-cost gate (
--gate-corpus=all, base549aa83d6, ona97a9ba6b; the two commits after it change corpus programs and the ledger only, no compiler source): PASS. 5,195 programs identical, 96 fail to build on both sides, none differ, noruntime-onlyrow. Bench geomean +0.31% [95% CI -1.10%, +1.79%], within tolerance; self-compile +1.55% [-2.09%, +2.21%] and fixtures +3.15% [-1.05%, +7.35%], both inconclusive; the candidate compiler source builds with the candidate. - Unit:
packages/compiler/test/handle-member-arms.test.ts(the helper for each call shape; no helper for an argument count the member does not take; no helper in a program without a table handle), the validator test for the newdynTestkind, and the 1,021 tests underpackages/compiler/src/frontend/loweringandsrc/ir. - Generated:
pnpm generate:ir-codec,pnpm generate:ir-fields.pnpm test:ts7 --baselines-only,pnpm lint,pnpm format:check,pnpm node-compat:check: clean. - Native bootstraps and the full lanes (this changes lowering): running; the result is added here when the round ends. Draft until then.
Left as it is
- A member read is still wrong, silently, as on
main. On an untyped socketsocket.setTTL?.(64)isundefined(Node:64),typeof socket.setTTLis"undefined"and"setTTL" in socketisfalse: the handle's dynamic view answersundefinedfor a member it does not carry. This PR serves the call; the read, the optional call andinare the next slice of the member tables (a table member read on a dynamic value answers a function over the same builtin). Recorded with a repro. - A computed member name (
socket[name](64)) reaches the runtime's dispatch and its named refusal, as before. - An argument of another type (
setTTL("64"): Node raisesERR_INVALID_ARG_TYPE) and extra arguments (setTTL(64, 1): Node ignores the extra one) take the old path and fail loudly. Serving them means giving the builtins Node's argument validation. - Only dgram has a member table so far. The TLS and net socket getters and the stream state properties are the next tables; each then gets this for free.
🤖 Generated with Claude Code
Activity
5This pull request is a draft
Merging is unavailable until it is marked ready for review.
The latest updates on your projects. Learn more about Vercel for GitHub.