Repository navigation
Unify lazy JSON arrays into GC_TYPE_ARRAY: laziness as element state, not an object type #10098
Description
The problem
Laziness is encoded as an object type. A JSON.parse result is GC_TYPE_LAZY_ARRAY (9) with a LazyArrayHeader, not GC_TYPE_ARRAY (1) with an ArrayHeader. So every consumer of arrays has to know that a second array-shaped thing exists, and each one bolts on its own branch.
86 sites across 60 files currently key on GC_TYPE_LAZY_ARRAY. They fall into three shapes:
obj_type == GC_TYPE_ARRAY || obj_type == GC_TYPE_LAZY_ARRAY— "treat both as an array" (the majority)if obj_type == GC_TYPE_LAZY_ARRAY { force_materialize_lazy(...) }— the materialize gateif obj_type != GC_TYPE_LAZY_ARRAY { ... }— a fast path that declines lazy receivers
The failure mode is forgetting one, and it is silent until it is loud:
JSON.stringify's tag dispatch omitsGC_TYPE_LAZY_ARRAY, so the unknown-object fallback reinterprets aLazyArrayHeaderas aStringHeaderand readsLAZY_ARRAY_MAGIC(0x4C5A5841) as a string length → 14 of 18 diagnostic cases SIGSEGV. The generic array serializer materializes correctly; only the dispatch is missing an arm.- The codegen indexed inline cache never knew about the tag at all, so every
parsed[i]fell through four layers of re-classification — ~237 retired instructions forrows[7].id. Fixed symptomatically by adding two more tiers to the cache (branchperf/json-lazy-array-index-ic), i.e. by making the decision tree bigger. Object.definePropertyon an index of a lazy array is installed and then ignored by reads (JSON.parse lazy array: Object.defineProperty index accessor is bypassed by reads #10097).
The header is already half-admitting the design is wrong. cached_length sits at offset 0 so codegen's inline .length load works on it, and magic sits at offset 4 so clean_arr_ptr's length > capacity check passes. It is impersonating an ArrayHeader for two fields. The right design doesn't impersonate — it is one.
Proposal: laziness as a state of element storage
today: LazyArrayHeader { cached_length, magic, root_idx, tape_len, tape,
blob_str, materialized, materialized_elements,
materialized_bitmap, walk_idx, walk_tape_pos,
cumulative_walk_steps, sequential_streak }
+ a separately allocated element cache + bitmap + maybe an ordinary array
after: ArrayHeader { length, capacity } [slot0 slot1 ... slotN] ← GC_TYPE_ARRAY
unmaterialized slots hold TAG_UNMATERIALIZED
tape/blob/walk state in ObjectMeta (tape is not a GC edge, as today)
What collapses:
- The sparse cache disappears.
materialized_elements,materialized_bitmapand the bitmap-is-authoritative rule become "the element slot holds the object now." The identity invariant (parsed[i] === parsed[i]) is then a property of the storage rather than a cache that has to be kept coherent. materializeddisappears, and with it the three-state header machine andresolve_materialized_array.cached_lengthstops being a mirror..lengthis the real length, so the staleness problem — and the freshness guard the new IC tier needs — goes away.- The ordinary-array IC tier serves every materialized read with no new code. It already sends a non-value slot to the miss helper; the miss helper materializes one element from the tape and stores it into the slot. Both tiers on
perf/json-lazy-array-index-icget deleted. Array.isArray,typeof, species,instanceof, GC element tracing, heap snapshots — all the "treat both as an array" sites become unconditional.
Memory is neutral-to-better: the sparse cache already allocates 8 B/element plus a bitmap on demand, and the pre-allocated slots replace both. For the 20 MiB fixture that is ~11 MB of slots against an 18 MB input, less than the tape.
Second-order win. The R17 memory diagnosis traced the 505 MiB (16 KiB scan) and 300 MiB (1 MiB scan) RSS blowups to "lazy headers stay old and immovable, and their materialized/cache edges can keep young graphs alive until full collection" — the first copying minor attributes ~12.1 MB of promoted objects to remembered_set/lazy_array. A unified lazy array is an ordinary young array with a malloc'd side buffer; that pinning stops existing by construction rather than being tuned.
Decision 1: the sentinel
Use a distinct TAG_UNMATERIALIZED in the 0x7FFC singleton namespace, not TAG_HOLE.
The namespace has room and the precedent is exact — TAG_HOLE (0x7FFC_0000_0000_0010) and the TDZ sentinel are both "a value that lives in slots and is translated at read boundaries, and must never be observable." TDZ is the closer model: it deliberately does not coerce to undefined, because silently coercing is how you lose the bug.
Reusing TAG_HOLE would mean zero IC changes, but a leaked sentinel would then be indistinguishable from a genuine hole — it would read as undefined, be skipped by Object.keys/in/forEach, and produce a silently short array. A distinct tag makes the same leak a loud, greppable wrong value, and costs one masked compare in the IC's existing hole test (both are 0x7FFC_... singletons, so (bits & ~0x18) == TAG_HOLE-style folding covers both in one branch).
Decision 2: eager slot allocation
Allocate length × 8 at parse. Sizing is already known from the tape's ARR_START. This is what makes the array a real array from the first instant, so nothing downstream needs a "not yet sized" state.
Touch list
Must change (the gate becomes a flag, not a type):
json_tape.rs—alloc_lazy_array,install_materialized,force_materialize_lazy, the header struct,json_tape/layout.rs's offset pinsjson_tape/cached_read.rs—lazy_getbecomes the miss helper: materialize one element, store into the slotjson_tape/mutation.rs—resolve_materialized_arraydeletedgc/layout_slot_visit.rs,gc/types.rs,gc/verify.rs,gc/verify_diag.rs—GcRewriteDescriptorKind::LazyArraybecomes ordinary array element tracing plus one side-buffer edgegc/oldgen.rs— theremembered_set/lazy_arraypromotion patharray/header.rs,array/alloc.rs,array/indexing.rs,array/is_array.rs,array/iterator.rs,array/species.rs,array/generic.rs,array/flat_clone.rs,array/concat_reverse.rs,array/subclass_packed_index.rs,array/iter_object.rs— the dual-tag tests become unconditionaljson/stringify_api.rs,json/reviver.rs— including the missing-arm SIGSEGVperry-codegenexpr/index_get.rs,expr/index_get/inline_dyn_typed_array.rs,expr/array_methods.rs— delete both tiers fromperf/json-lazy-array-index-icand the!= GC_TYPE_ARRAYexclusions
Sweep (the ~50 remaining "treat both as an array" sites): typed_feedback.rs, value/dyn_index.rs, object/shapes.rs, object/polymorphic_index.rs, object/prototype_chain.rs, object/from_entries.rs, builtins/formatting.rs, node_stream_readwrite.rs, child_process/v8_serde.rs, gc/heap_snapshot.rs, proxy.rs, thread.rs, dyn_eval/bridge.rs, perry-stdlib worker_threads.rs / fetch_blob.rs, and the rest of the 60-file list.
Deletable once done: LAZY_ARRAY_MAGIC and every defensive magic check, the cached_length-at-offset-0 contract, the magic-as-fake-capacity contract, json_tape/layout.rs.
Validation this needs (and why it can't ride along with a perf PR)
- A sentinel-leak gate: assert
TAG_UNMATERIALIZEDis never observable from TS across the whole gap suite, not just the JSON fixtures. - Every materialize gate re-proved:
forEach, spread,Object.keys,for..in,JSON.stringify, structured clone, worker/thread serialization, heap snapshot. - GC: the rewrite descriptor change wants
PERRY_GC_SCHEDULE_SEED+PERRY_GC_PROTECT_FROMSPACEacross the JSON fixtures, plusgc_root_dominanceand the runtime-root-holder inventory, since a side buffer holding a heap pointer is exactly the invisible-cache class in CLAUDE.md. - The full 50-row matrix plus the access rows — this should be RSS-positive on the scan rows, and that claim needs measuring, not assuming.
Order
This lands after the indexed-cache tiers (perf/json-lazy-array-index-ic), and it should be a subtraction: the diff deletes both tiers, the three-state machine, resolve_materialized_array, the offset pins, and the class of bug where a new consumer forgets an arm. If it doesn't come out net-negative in lines, the design didn't land.
Refs #10097 (defineProperty on a lazy index — subsumed by this), #793.
Activity
proggeramlug commentedon Sep 12, 2026
Investigated: the RSS payoff is real, and the blocker is narrower and harder than this issue assumed
Measured on
records_array_16k:scanwithPERRY_GC_DIAG=1, against current main:Every minor promotes essentially the whole nursery, copies nothing, frees nothing. Old-gen climbs without bound while the arena rebaselines its own trigger upward (134M→268M→536M→1073M), so the full collection that would reclaim it never arrives. That is the 205 MiB against Node's 62 MiB, and it confirms R17's diagnosis precisely: a dead lazy cluster holds its entire element graph live through the remembered set.
The single allocator call behind it
LazyArrayHeaderusesarena_alloc_gc_old_born_tenured. I initially read that as vestigial — the allocator's own doc says the header only reached the large-object arm because its tape used to be inline, and #7539 shrank it to ~88 bytes — so I tried the targeted fix: decide the cluster's generation by cache size, and decide it once for header + cache + bitmap so #7546's one-generation invariant still holds. Small clusters become nursery-resident and a minor reclaims the lot.That builds and most of the JSON suite passes. It is not safe, and
gc/tests/lazy_tape_side_alloc.rssays why:json_tape_storekeys a tape by its owner's address. A header that moves silently orphans its tape registration.gc_type_is_movable(GC_TYPE_LAZY_ARRAY)isfalse— yet a nursery-allocated header does get relocated by the copying minor. Allocation site and movability policy disagree, which is worse than either choice alone.try_stringify_lazy_arrayreadsblob_bytesoff a raw header and then allocates the result string; when the header became nursery-resident the minor relocated it out from under that read andJSON.stringify(parsed)emitted a string of NUL bytes on 3 of 60 iterations.So the pinning is load-bearing, not leftover. I reverted the change rather than ship a latent non-determinism.
What the unification actually has to do first
The element-storage redesign in the issue body is still the right end state, but it cannot start from the storage layout. The ordering is:
json_tape_storeoff the owner address — a stable owner id, or an owner slot the collector rewrites. Until this lands nothing about the header can move.GC_TYPE_LAZY_ARRAYmovable and flip the allocation to size-decided, keeping header/cache/bitmap in one generation.json_tape.rsalready uses rooted handles in 20 places, so this is bounded.try_stringify_lazy_arrayis the known one; the audit needs--trace llvmplusPERRY_GC_PROTECT_FROMSPACE+PERRY_GC_SCHEDULE_SEEDover the JSON fixtures, since a stale header deref is exactly the GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154 class that surfaces cycles later asTypeError: value is not a function.TAG_UNMATERIALIZEDstorage change.Step 1 is the real gate and deserves its own issue; steps 2–3 are where the RSS win is actually collected, and they may be enough on their own without step 4.
Two tests encode the current invariant and will need rewriting, not deleting
test_lazy_header_is_small_but_stays_old_gen_and_immovableand the twocallback_scannershandle tests assert immovability directly. Their replacement assertion is that the rooted handle resolves to wherever the collector left the header, and thatalloc_lazy_arrayreturns the refreshed address rather than the stale one — a stronger property than "it never moves", and the one callers actually need.gc(json): make lazy JSON arrays movable and nursery-resident (#10098)
proggeramlug commentedon Sep 13, 2026
Closed by #10136 ("Closes #10098"), landed via merge train #10188 (main
5cec2fbbc9).