#3233

Serve handle table members on a dynamic receiver

Draftcramforcewants to merge intofrom

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 TypeScript any receiver (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 undefined for a void builtin). 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 through any and unknown) and .mjs (the socket as a function parameter) exercise all fourteen table members, the EBADF of an option on an unbound socket, and the TypeError of a value that is not a socket. Both match Node 24.22.1 in stdout, stderr and exit status, plain and with SCRIPTC_SAN=1 (reference-count audit included). Every other dgram program passes both lanes (27 plain cases with tests/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, base 549aa83d6, on a97a9ba6b; 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, no runtime-only row. 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 new dynTest kind, and the 1,021 tests under packages/compiler/src/frontend/lowering and src/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 socket socket.setTTL?.(64) is undefined (Node: 64), typeof socket.setTTL is "undefined" and "setTTL" in socket is false: the handle's dynamic view answers undefined for a member it does not carry. This PR serves the call; the read, the optional call and in are 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 raises ERR_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

5
vercel[bot]6d ago

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 9:04am UTC

This pull request is a draft

Merging is unavailable until it is marked ready for review.