Skip to content

Write untyped stores into live records and keep one object across prefix casts - #2366

Open
cramforce wants to merge 7 commits into
mainfrom
om/s3-pr2
Open

cramforce wants to merge 7 commits into
mainfrom
om/s3-pr2

Conversation

@cramforce

@cramforce cramforce commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Stage S3 of the single object model, second PR (now also the frozen-record fix). Untyped writes of a typed record no longer turn its capsule into a permanent snapshot, and a cast into a record type whose storage is a prefix of the record's keeps one object (decision D3).

Writes through the record (descriptor-driven capsules)

  • Each live record's ops table (ScrDynTypedRecordOps) gains a set hook (liveRecordSet in expr-stream-bridges.ts). It is emitted only when the program stores through an untyped view (!liveViewsReadOnly). An untyped write of a present declared field converts the value with the field's checked conversion and stores it in the native record in place. Before this PR, every such write materialized the whole view, wrote it, ran the full commit conversion, and left the capsule answering from the view from then on.
  • Absent optional fields (whose write changes key order), frozen records, inline-union fields, and values the field does not accept still take the snapshot path. That path still raises the same TypeError: expected number at $.x, got string.
  • Object.assign from a plain data-only object stores each key the same way.
  • Checked conversions and matches of a live record capsule read a fresh, uncached snapshot (scr_dyn_typed_ref_peek), so casting a record no longer turns its capsule into a snapshot either.

Prefix casts keep identity (S3g)

A checked conversion of a record capsule (shape A) into record type B passes the pointer when all of these hold:

  • B's fields are A's leading fields, with the same names and types, so they sit at the same offsets.
  • A's other fields hold no references.
  • Neither shape has a shape word, a cycle header, an overflow map, key-order variants or accessor slots.
  • Both shapes are equally freezable.

The compiler decides this per pair (prefixViewSources in dyn.ts). B's release then frees the block correctly, because frees do not depend on the size class.

At run time, scr_dyn_typed_ref_prefix_view refuses the pass while A keeps undeclared source properties, and records A's capsule parameters in the record-source table (ScrRecordPrefix). That entry is freed with the record. Every later box of the object under any record type is then A's capsule, so (n as unknown) === src holds. B's reflective surfaces also answer through that capsule: JSON.stringify, Object.keys and inspect show the whole object, as in Node.

Copies remain in these cases:

  • Incompatible layouts ({a,b,c,d} cast to {a,c}). Typed code reads fixed offsets, so two storages cannot share one object until records are read through their own layout (S3e: stamped layout id plus descriptors).
  • Prefixes whose extra fields hold references (strings, nested records). B's static release would leak those fields; this needs release through the object's own storage (same prerequisite).
  • Static width copies. These are unobservable by construction, because an observable one gets SC6001.

Adoption

scr_dyn_decl_become already rewrites shared generic nodes into live capsules in place. With the set hook and peek, those capsules now stay live through writes and casts; this is the forwarding layout of DESIGN §4.5 step 4. Adoption itself needs no separate change.

Tests

  • New corpus programs record-dynamic-writes.ts and record-prefix-identity.ts write through one name and read through the other, and cover reflective views and identity.
  • New benchmark workload record-untyped-writes.
  • Differential corpus on Linux Sandboxes, 8 shards per lane: the plain lane passes 4,282 of 4,282 and the sanitized lane 4,282 of 4,282. pnpm test:sandbox failed twice in sandbox preparation and completion probes, both infrastructure failures, so the lanes ran with the shard runner instead.
  • self-hosting-frontend-lowering, self-hosting-native-driver (bootstrap: the rebuilt compiler emits itself), dyncheck and decl-boundary pass.
  • runtime-tree-shaking "static hello" fails identically on main: 99,008 B against a 98,304 B limit. This PR leaves the hello binary byte-identical in size to main. The excess also predates Store ordinary objects as a layout plus value slots #2353: the commit before it (9f47d74) builds hello at 99,048 B, and Store ordinary objects as a layout plus value slots #2353 shrank it by 48 B.
  • The sanitized self-hosting-native-frontend* suites fail at their Node-hosted compile step on main as well.
  • Zero-cost gate (baseline main c5e8a87, 3,233 programs, including the candidate's self-host build): 1,291 programs are identical. LLVM changes in 1,284 programs, all with dynamic boundaries. The changed functions are only checked-conversion and match helpers (sc_dc_* in 1,048 programs, sc_dm_* in 186) and the ops-table type (50). The other differences are executable-only, inside runtime functions (scr_dyn_key_set, scr_dispose_observers, sc_ldr_*_rops). No program gains a dynamic-tier symbol.
  • bench-runtime (--layouts=4 --runs=24): record-untyped-writes is 68.6% faster (4,759 ms to 1,494 ms, CI -69.3% .. -67.6%). dynamic-methods, dynamic-properties, json-records, json-unions, keyed-option-writes, optional-fields, record-boundaries, throw-chains and validate-errors are all neutral.
  • Test262, base against candidate in one sandbox: every outcome is identical in Object (6,802), Reflect (306), JSON (330), Proxy (607) and Function (893).

Found, not fixed

An untyped write to a field of an Object.freezed record literal or function result succeeds natively, while Node ignores or throws. Main behaves the same: scr_rc_frozen answers false for these records.

Frozen records seen through untyped references, and sloppy-mode writes

  • Object.freeze of a fresh record or array literal now sets the native frozen bit. Before, it compiled to the literal, so an untyped reference saw an unfrozen object: Object.isFrozen answered false and writes succeeded.
  • Untyped property writes in sloppy-mode code now ignore an assignment that strict mode refuses. These writes are listed per site in IrModule.sloppyDynWrites, decided by isStrictCode, and call scr_dyn_key_set_sloppy or its computed form. The refused assignments are a read-only or frozen property, a non-extensible object and a frozen array element. Only the TypeError that the refused assignment raised is dropped; a setter's exception still propagates. Computed-write dispatch helpers are shared only by writes of the same mode.
  • New corpus programs: frozen-record-untyped-writes.ts (strict) and frozen-untyped-writes-sloppy.cjs (sloppy).

Validation round (bin/validate, HEAD 6878c5b against c5e8a87)

  • Test262: no outcome changed in Object, Reflect, JSON, Proxy or Function.
  • bench-runtime, 52 workloads: geomean -1.7%. record-untyped-writes is 68.8% faster, value-objects is 2.4% slower (CI +0.1% .. +3.7%), and the other 50 are neutral.
  • Gate: 1,290 programs are identical.
    • LLVM changes only in programs with dynamic boundaries: sc_dc_* in 1,048 programs, sc_dm_* in 186, and the ops-table type in 50.
    • Two typed programs that freeze a fresh literal also change: 1982-freeze-resolve-passthrough and array-sort-holes-published. Their frozen bit and guards are the fix.
    • The other differences are runtime-only.
    • Bench compile time +0.09% and self compile time +0.26%, both within tolerance.
  • Lanes: the failures were forin-keys, class-dynamic-dispatch and bench-dynamic-tier, all test expectations fixed in the commits after the validated head. Three others are not caused by this branch:
    • three-library release timed out in the lane. Run alone, it passes on both trees: 572 s on the base and 560 s on the candidate, against its 600 s limit.
    • runtime-tree-shaking fails on main too.
    • The lane-*-native jobs failed at startup with SCRIPTC_TEST_SHARD must look like "2/3", got "native", a harness setup error, not a test failure.

D3 instrumentation (full differential corpus, cast copies and prefix passes)

Cast copies Programs with copies Prefix passes
main c5e8a87 83 18 0
This branch 84 19 6, all in the new record-prefix-identity.ts

The extra copy is in a new corpus program. None of the existing 83 copies is a prefix cast whose extra fields hold no references, so S3g as it stands removes none of them. Retiring them needs either reference-holding prefixes (B's teardown would have to release the source storage's extra fields) or identity across incompatible layouts. The second conflicts with fixed-offset typed access (DESIGN §4.3); see NOTES.md, "S3e request".

🤖 Generated with Claude Code

cramforce and others added 3 commits October 26, 2026 15:05
…nter

An untyped write of a present declared field of a live record capsule now
stores into the native record through a per-shape `set` hook (checked like
the commit, refusing to the snapshot path for values the field cannot hold),
so the capsule keeps answering from the record instead of turning into a
permanent snapshot. Object.assign from a plain object takes the same path,
and checked conversions and matches read a capsule through a fresh,
uncached snapshot.

A checked conversion of a record capsule into a record type whose storage
is a same-offset prefix of the capsule's record (and whose extra fields hold
no references) passes the pointer: both names keep one object, writes show
through either, and the record remembers its own capsule parameters, so
every later box of it and the narrower type's reflective surfaces answer
for the whole object (decision D3). Other cross-layout conversions keep the
refreshed copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 27, 2026 3:19am UTC

…chmark

- forin-keys.test.ts matches the record-ops table with its eighth field, the
  store hook, which is null for a record no untyped code writes.
- bench/record-untyped-writes writes typed records through `unknown` and
  `Object.assign` views, so it links dynamic-tier symbols; DYNAMIC_WORKLOADS
  lists it.
- The sanitized self-hosting emission test gets a 600 s timeout. It takes
  about half of the default 300 s on an idle 16-core host, with or without
  these changes, and exceeded the default on a loaded CI shard.
cramforce and others added 2 commits October 26, 2026 20:16
… writes

Object.freeze of a fresh record or array literal now sets the native
frozen bit instead of compiling to the literal, so an untyped reference to
the value sees it frozen: Object.isFrozen answers true and strict-mode
writes throw Node's TypeErrors.

Untyped property writes in sloppy-mode code (IrModule.sloppyDynWrites,
decided per write by isStrictCode) call scr_dyn_key_set_sloppy and its
computed form, which drop exactly the TypeError a refused assignment raises
(a read-only or frozen property, a non-extensible object, a frozen array
element) while a setter's exception still propagates. Computed-write
dispatch helpers are shared only by writes of the same mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — d2abf522 Deployed Oct 27, 2026 by vercel[bot]
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