#3281

Link the views of program error classes only with what can box an error

Opencramforcewants to merge intofrom

What

A typed program that declares a class extending Error no longer links the dynamic object model. This is the root #3160 left: with it, new Error("…") in typed code stays out of the object model, but any class AppError extends Error brought all of it back.

class AppError extends Error {
  constructor(message: string, readonly status: number) { super(message); this.name = "AppError"; }
}
function f(n: number): number { if (n > 2) throw new AppError("too big", 400); return n; }
try { console.log(f(5)); } catch (e) { console.log("caught"); }

326,304 bytes before, 100,784 after, and no dynamic-tier symbol.

Why it was linked

The emitted main called scr_error_register_dyn(vtable, view) once per program class rooted at Error. view builds the class's checked-dynamic view: a typed reference whose snapshot function calls scr_error_dyn_fields, scr_dyn_obj_set and the per-field conversions, and whose commit function runs the checked casts back. main is a link root, so the views and everything behind them were linked whether or not anything could ever ask for one. The only reader of the registered list is scr_dyn_from_error.

The change

  • The reader names the table. The program object defines scr_error_dyn_types, a constant pointer to a constant table of {vtable, view} rows ended by a null row. The runtime holds a weak null default (the mechanism scr_stdout_buffering and scr_detached_exit use), and scr_dyn_from_error walks the table. scr_error_register_dyn, its per-thread list and its atexit teardown are deleted. Nothing but scr_dyn_from_error names the table, so it is live exactly when the program links something that can box an error: compiled code converting an error to an untyped value, or one of the runtime paths that do (a rejection reaching allSettled, an 'error' event, scr_error_set_prop, …). There is no flag, predicate or analysis; liveness is the linker's reachability from the one reader.
  • View-only code is droppable. A program object is compiled as one code section, which the linker's section GC keeps or drops whole. (This is why the table alone, which #3160's author prototyped, moved nothing.) On ELF every definition first created while the views are emitted gets a section of its own, named as -ffunction-sections would name it: the table, sc_error_view_N, the snapshot and commit functions, and the per-field helpers and field-hook tables nothing had asked for before. A helper live code also reaches is kept, alone. A section attribute cannot change behaviour.
  • Runtime ABI 49, in a commit of its own. A program object built for the table links against an older pack without an error and silently has no views; the marker guards against that mixing. The gate below compares the table commit against the marker commit, so the rename, which is in every executable's symbol table, is not in its rows.
  • The committed runtime profile named scr_error_register_dyn and the old marker, and runtime-profile.test.ts requires every record to name a runtime function. The two records are dropped with the helper's merge-profiles --keep-functions over the committed profile (1,550 to 1,548 records, 618 of 23.3 billion counts; no new profile is collected). The runtime pack built with the filtered profile is byte-identical to the one built with the profile as it was, for both commits (1,431 objects each, compared before the last rebase).

Platforms: Mach-O is dead-stripped by symbol and needs no placement (checked with a two-file C model of the weak pointer, the table and a view: unused, all three are gone; used, the strong table overrides the weak null and the view runs). Windows (MinGW COFF) keeps today's size, because COFF section GC only drops COMDAT sections; behaviour is the same. Workers and thread-instanced libraries read one shared constant table where each thread had its own registered list; class vtables are shared constants already. Library mode resolves the weak default in its relocatable link, before symbols are localized.

Alternatives considered: emitting the registration only when the IR boxes an error (the runtime boxes errors too, in 24 units, so the fact needs the runtime's call graph, and a miss silently loses a subclass's fields in a view); views as data the runtime interprets (a second representation of every field conversion the backend generates); function sections for the whole program object (changes every executable). The installed-table shape of the runtime-gating design is what this uses, with the linker doing the installing: a constructor or main would be a link root.

Numbers

Linux x64, release, Intel Xeon @ 2.90GHz. Base is the marker commit (fd79c380c, main plus the rename), candidate the table commit (5e979ae86).

ProgramBaseThis PRDynamic-tier symbols
console.log("hello")75,22475,2240
throw and catch new Error("too big")96,74496,7440
the example above326,304100,78432 to 0
the same, with instanceof AppError, .status, .message, .name read327,368102,08832 to 0
tests/corpus/8860-typed-error-subclasses.ts454,264229,75232 to 0
bench/layered-errors420,048243,64832 to 0
bench/validate-errors408,984232,97632 to 0
class AppError extends Error {} (synthesized constructor)326,664310,04831 to 29
the example with String(e)329,304312,57632 to 29
the example viewed as unknown (Object.keys, a field through any)627,512627,26458 to 58
tests/corpus/8861-error-subclass-linked-views.ts834,608834,39263 to 63

One class with one field of each kind, thrown, caught and read typed, goes from 32 dynamic-tier symbols to none for each of: number, string, boolean, record, optional record, arrays, a union of primitives, an optional and a nullable class, a class, Map, Set, Date, bigint, tuple, bytes, RegExp, Error | null, the class itself, unknown.

Across the corpus and benchmarks, 117 programs change by design (they declare a class rooted at Error): 107 executables are smaller, 7 larger by 56 to 304 bytes (a kept view's functions are aligned as sections of their own), 3 keep their size; 1.77 MB less in total. Eight end with no dynamic-tier symbol: the two benchmarks above and 1029-async-eager-chains.ts, 1304-errors-uncaught.ts, 1305-errors-async-rejections.ts, 1478-promise-reject.ts, 1648-ee-error-event.ts, 2451-private-fields.ts (median 393,076 to 180,312 bytes). No executable gains one. bench/throw-chains keeps its 51: it throws plain objects.

One-time gate exception

scr_dyn_from_error and the removed registration are in scr_json.c, a unit every program with a checked-dynamic value links, so this PR has runtime-only rows, once. Zero-cost gate at 5e979ae86 against fd79c380c, --corpus=all, 5,349 programs compared, allow pattern \bextends\s+(?:[\w.]+\.)?\w*Error\b:

  • 2,426 identical, 103 fail on both sides for want of a libnode pack.
  • 2,704 runtime-only: IR and LLVM identical, the executable differs only in runtime code. They link scr_dyn_from_error, or a function of the unit whose static data moved when the registration's two variables went. Sizes: 2,166 smaller by 32 to 120 bytes (1,467 of them by 48), 538 larger by 8 or 16 bytes. 13 of the 2,704 are benchmark workloads, all of them listed in DYNAMIC_WORKLOADS. (Measured before the rebase, against a main without #3199's unwind-table change, the same commit showed 3,364 such rows.)
  • 111 match the pattern and 6 differ outside it, because their error classes are in a spelling or a package the pattern does not see: 5976-builtin-class-aliases.ts (const RE = RangeError; class … extends RE), 6237-error-subclass-delete-stack, 7614-npm-local-class-per-instance, 7132-jose-pbes2, 6295-jose-hs256, 6331-jose-root-entry. The gate's verdict is FAIL on the runtime-only rows and those six.

A stronger check than the pattern: for each corpus and benchmark program, the LLVM the candidate emits is compared with the base's after undoing the one change (the registration calls and their declaration removed from the base; the table and the section attributes removed from the candidate; the views numbered by first appearance). Of 5,348 programs, 5,234 are identical as emitted, 114 are equal after the rewrite (1,132 section attributes), and none differs otherwise. IR is identical for every program: the frontend is not touched.

Compile time is within tolerance: bench geomean +0.23%, self -0.69%, fixtures -0.84%.

What still links the object model in typed error code

  • A ctor-less subclass (class AppError extends Error {}): its synthesized constructor takes and forwards two checked-dynamic values to error.ctorOptions (326,664 to 310,048 bytes here: the views go, the constructor stays). A lowering change, not a link question.
  • A message typed string | undefined. Lowering.
  • String(err), err.toString(), a template. %error.toString.virtual boxes the receiver to call the selected method, so these link scr_dyn_from_error and, correctly, the views with it.
  • console.log(err): scr_insp_error_node reads scr_error_dyn_fields.
  • A catch binding read after the checker narrowed it to a union of error classes (if (e instanceof A) … else if (e instanceof B) … else throw e; if (e instanceof Error) …), or copied into a const: the union is converted to an untyped value, which boxes every class in the program. Found while writing the corpus program; not addressed here.

Tests

  • tests/corpus/8860-typed-error-subclasses.ts with tests/harness/typed-error-dynamic-tier.test.ts: four classes over Error, TypeError and RangeError with number, string, record and Map fields, methods, a subclass of a subclass, rethrow, finally and async rejection; the release executable defines no dynamic-tier symbol.
  • tests/corpus/8861-error-subclass-linked-views.ts: the views found through the table whoever boxes the error (compiled code, Promise.allSettled, Promise.any, a cause, a .catch handler), a subclass before its base, one view per instance, writes through the view.
  • packages/compiler/test/error-constructor-entries.test.ts: the table and the section placement on ELF, no placement on Mach-O, no table without error classes.
  • bench/layered-errors and bench/validate-errors leave DYNAMIC_WORKLOADS; tests/harness/bench-dynamic-tier.test.ts now requires them to link no dynamic-tier symbol.

Validation

One full round at 5e979ae86 against fd79c380c (Vercel Sandboxes, Linux x64), after an earlier full round on the same change before its last rebase (at 834bac6e9; one conflict since, a brace in the loop above the views).

  • Differential corpus and package tests, both lanes with a libnode pack: plain 22,044 passed, sanitized 18,812 passed. One test not passed, packages/compiler/src/native/prepare.test.ts "warm frontend reuse" in the plain lane, which also failed in the rounds of #3160 and #3231 and does not involve an error class.
  • Native bootstraps: plain and sanitized (SCRIPTC_SAN=1), both pass. The compiler declares error classes, so it is one of the 117 changed programs.
  • Test262 (Function, Reflect, JSON, Proxy, Object): 0 regressed, 0 gained.
  • bench-runtime: 65 workloads, geomean +0.8%, 65 neutral. The error-heavy workloads (throw-chains, layered-errors, validate-errors) are among them; their throw and catch paths are untouched.
  • Lint, format, pnpm test:ts7 --baselines-only, runtime-profile.test.ts.
  • Not checked here: macOS and Windows links. The override of the weak default is the mechanism scr_stdout_buffering already uses on both; CI covers them.

🤖 Generated with Claude Code

Activity

9
vercel[bot]6d ago

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

ProjectDeploymentActionsUpdated
scriptc ReadyPreview, v0Nov 7, 2026 1:31pm UTC
vercel[bot]reviewed5d ago
cramforcereviewed5d ago
cramforce5d ago

CI note for b185d3f533: the one red job so far, "test (Linux, plain, 9/20)", fails only packages/compiler/src/executable/executable-cache.test.ts > "every build and run option is in the cache keys or says why it need not be" (three options without a rule). That is red on main itself since #3260 and #3218 merged without each other; #3306 adds the three rules. Nothing in this PR touches it; the shard needs a re-run once #3306 is on main. The island value matrix job is the one this push was about.