#3265

Keep the dynamic handle members and error nodes out of typed net, HTTP and child programs

Opencramforcewants to merge intofrom

A fully typed net server, HTTP server or child-process program linked the dynamic object model: about 290 KB of a 616 KB HTTP server. Three kinds of reference held it, and none was the program's doing. This cuts them. 117 corpus programs now link no dynamic-tier symbol at all, and none gains one.

What held it

Traced with lld's --why-live and a pass over the live relocations of each link (one chain per symbol hides every root but one).

  1. The emitted main installed the handle ops of every unit the program links (scr_net_dyn_install, scr_http_dyn_install, scr_child_dyn_install). The ops tables name every *_dynh_* function.
  2. scr_error_set_prop built the error's dynamic node at the throw site. A connect error's address and port, an exec error's status, output and pid: each call ran scr_dyn_from_error, so any program that could raise such an error linked the node builder and everything behind it. With the installs gone this alone kept 185 KB.
  3. Typed paths that named a dynamic entry directly: the request settle path called a signal option's disposer through scr_dyn_call; the accept path built the 'drop' event's data object; setHeader with a typed string went through the dynamic coercion; a request without an agent boxed the global agent; the zero-argument write callback shared a binder with the dynamic one.

What changed

  • Units install their handle ops where a handle is first boxed. scr_net_socket_to_dyn, scr_net_server_to_dyn, scr_http_req_to_dyn, scr_http_res_to_dyn, scr_http_client_to_dyn and scr_child_to_dyn install on first use, the compiler's to-dyn helpers call them (DYN_HANDLE_BOX_FN, the mechanism scr_dgram_to_dyn already uses), and every internal box goes through them. The linker keeps a unit's dynamic members exactly when linked code can box one of its handles. In a program that links the HTTP unit, net handles box through scr_http_server_to_dyn / scr_http_socket_to_dyn, so a boxed HTTP server always has its 'request' hook.
  • The receiver is the one box no unit sees coming: a typed fire pushes (pointer, tag) and scr_dyn_this_get boxes by tag when dynamic code reads this. New runtime feature dynReceiver (the module has dyn.this or dyn.generatorThis, or converts a dynamic value to a function type); main installs before %main only then. Entry points that take a dynamic listener, and the stream bridge that reads a child's stdio through its dynamic members, install too.
  • An error's attached properties wait for its node. scr_error_set_prop records (key, value); scr_dyn_from_error applies the recorded ones when it builds the node, in the throw site's order. A recorded code keeps its position (sites that stamp the code slot and then set code after library and reason rely on it). 'key' in err from typed code answers from the record without building the node; util.inspect builds it. Every call site keeps its call; scr_child_error_self uses the new scr_error_set_prop_self.
  • The typed paths go through a pointer their dynamic entry sets (scr_http_client_add_abort_signal, scr_net_server_on_drop) or a typed helper (scr_http_validate_header_text, scr_http_request_global). The pointers have external linkage: the compiler folds a static one with a single stored value back into a direct call.

The new functions in scr_json.c sit at the end of the file and are named in the middle only through a constant table, so the unit's existing functions are emitted in their existing order and programs that link none of this keep their bytes.

Behaviour

No change, with one exception that moves towards Node: an error's node is built at the first dynamic read instead of at the throw site, so typed code that assigns err.message before any dynamic read now sees the new message through any as well (the node used to hold the throw site's message). Pinned in 8896-net-error-properties.ts.

New corpus programs, differential against Node in both lanes:

  • 8885-net-typed-server-echo.ts, 8886-http-typed-server.ts, 8898-child-typed-exec.ts: fully typed. tests/harness/corpus-dynamic-tier.test.ts holds them to zero dynamic-tier symbols, as bench-dynamic-tier.test.ts holds the benchmarks.
  • 8887-net-handles-reach-unknown.ts, 8888-http-handles-reach-unknown.ts: typed handles that reach unknown code late, after typed listeners were registered, and a dynamic 'request' listener on a typed HTTP server.
  • 8889-js-net-listener-receiver.js: listeners whose only way to the handle is this.
  • 8896-net-error-properties.ts, 8897-child-error-properties.ts: the attached properties through any, in, Object.keys and JSON.stringify, in Node's order.

Numbers (Linux x86-64, release)

ProgramBeforeAfterDynamic-tier symbols after
typed HTTP server, 7 lines (createServer, listen)603,792322,9600
typed net server and client485,776255,3680
typed execFileSync CLI373,840148,5600
corpus/2982-http-static-close-all.ts616,120325,9040
corpus/1470-child-lifecycle.ts437,576225,6080
corpus/1462-exec-sync.ts395,216179,3520
bench/http-api848,824747,85633 (typed JSON.parse, not part of this change)

The first three were measured on 714482d49 against this branch; the rest are gate rows against e8c933e5d.

Zero-cost gate, --corpus=all, PASS, with --allow for sources that import net, http, https, http2, tls, child_process or assert (and the Intl programs, whose ICU check needs the pattern when their bytes change): 5,350 programs, 2,379 identical, 463 allowed, 2,405 runtime-only, 0 differ.

  • 292 executables shrank: median 102 KB, 149 of them by more than 100 KB, 35.6 MB in all. 117 programs (68 child-process, 43 HTTP, 6 net) dropped every dynamic-tier symbol. No program gained one.
  • 2,575 executables grew, every one a program that links dynamic-tier symbols before and after: median +256 bytes, 90th percentile +376 bytes, largest +4,496 bytes (0.42%, a dynamic HTTP program). This is the cost of the second change: scr_dyn_from_error tests whether a recorded property waits for the node it builds, and the dynamic HTTP and net programs also take the box functions. A dynamic program that builds no error node is byte-identical.
  • Benchmarks: 42 of 58 byte-identical, including every fully typed one. 15 dynamic workloads +192 to +320 bytes. bench-runtime: 65 workloads, 64 neutral, geomean -0.3%; the one outside tolerance (optional-fields +1.7% [+0.2%, +10.2%]) is a typed workload whose executable is byte-identical.
  • Full lanes in Sandboxes: plain 21,601 passed, sanitized 18,368 passed; the failures are test timeouts (three.js, an Effect pilot, two Test262 compile timeouts, one npm case that passes when rerun alone, a vendor-cache test and a beforeAll compile). Plain and sanitized native bootstraps pass.
  • DYNAMIC_WORKLOADS is unchanged: bench/http-api is the one workload this change touches, and typed JSON.parse still holds it.

🤖 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 12:18pm UTC
cramforce5d ago

Brought up to date with main (3a512b320; the head also merges cleanly with 50bcfe1b3): merge commit 39abceaef, and one commit the merge needed to build, 61eaf41c2.

What conflicted.

  • backend/llvm/emit-module.ts: #3232 installs the builder of a failed spawn's error object (scr_child_error_event_install) for its childErrorEvent fact, next to the child handle ops install this branch moved from every child_process program to the ones that read the dynamic receiver or add handle properties. The handle ops keep this branch's condition; the error-event install is main's, unchanged.
  • runtime/src/scr_child.h and ir/runtime-features.test.ts: both sides' declarations and tests, kept.
  • Not a textual conflict, found by the build (61eaf41c2): #3228 gave scr_http_request_pooled a parameter that says the header pairs are an http.request options record's. scr_http_request_global, this branch's typed path for a request that names no agent, called it without one. It now passes that they are, which is what main's null-agent arm does through the same function, so an agentless request's host and headers are checked before a socket is taken, as on main (#3228's 8854-http-request-option-header-checks passes).

How the two changes compose. #3232's builder sets a failed spawn's fields through scr_error_set_prop, which on this branch records them until the error's node is built; #3232's programs (8722-child-spawn-error-fields, 8730-child-spawn-error-typed) and this branch's 8897-child-error-properties pass together, so the recorded order is Node's. No program gains a dynamic-tier symbol: tests/harness/corpus-dynamic-tier.test.ts and bench-dynamic-tier.test.ts pass, and the gate reports none.

Re-run on the pushed head 61eaf41c2 (Vercel Sandboxes; the tests on 32 vCPU, Intel Xeon @ 2.90GHz):

  • Workspace build with the runtime pack rebuilt, pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only: pass.
  • Zero-cost gate, --gate-corpus=all, the allow pattern of the description, against 3a512b320 (which contains #3199): 5,388 programs, 2,392 identical, 474 allowed, 2,419 runtime-only, 103 fail to build on both sides, 0 differ. Before the merge, against e8c933e5d: 2,379, 463, 2,405, 103, 0.
    • 300 executables shrink (292 before): median 102 KB, 152 by more than 100 KB, 36.0 MB in all. 119 programs drop every dynamic-tier symbol (117 before); none gains one.
    • 2,592 grow (2,575 before), each a program that links dynamic-tier symbols before and after: median +256 bytes, 90th percentile +384, largest +4,576 (8854-http-request-option-header-checks, +0.24%).
    • 43 of 59 benchmarks byte-identical, every fully typed one among them; bench/http-api 849,888 to 748,920 bytes.
    • Compile time: benchmarks -0.32% [95% CI -0.69%, +0.61%] and fixtures -0.44% [-1.55%, +0.66%] within tolerance; self -0.75% [-4.67%, +3.11%], which the gate calls inconclusive.
    • I ran it without --gate-max-size-growth, so its printed verdict is FAIL for the 173 allowed programs that grow; there is no other failure line.
  • bench-runtime, two rounds of 66 workloads: geomean +0.1% with 63 neutral, then -0.3% with 64 neutral. The workloads outside tolerance differ between the rounds: dynamic-properties (a dynamic workload, +256 bytes) measured +4.2% [+1.3%, +12.4%] and then +0.4%; string-iteration +1.9%, n-body -5.1% and value-objects +2.0% are byte-identical executables; intern-table-map measured -8.5% in the second round only.
  • Differential and LLVM differential tests over every corpus program matching child|spawn|exec-file|execfile|exec-sync|http|net-|socket|tls|error-prop|error-fields|dyn-handle|8889-js-net|fs-read-directory|require-missing (this PR's eight programs and #3228's, #3232's, #3202's and #3259's among them): 588 pass plain, 588 pass with SCRIPTC_SAN=1.
  • corpus-dynamic-tier.test.ts, bench-dynamic-tier.test.ts, runtime-features.test.ts, link-feature-lists.test.ts, tests/harness/errors.test.ts: 222 pass.

The row for this PR in the gate exceptions list carries the new numbers. Not re-run on the merged head: the native bootstraps and the full lanes (the description's ran before the merge).

CI will show one failure that is main's until #3306 merges: executable-cache.test.ts > "every build and run option is in the cache keys or says why it need not be" (plain Linux lane).

🤖 Generated with Claude Code