Repository navigation
Large-bundle: object property values read undefined past a leading prefix (value/keys_array desync at scale) #6006
Description
Symptom
In a large bundled app (13 MB, esbuild, ~15.6k functions, compiled -Os), objects built by repeated computed-key assignment (axios's header merge — out[key] = value in a loop) read back undefined for their properties past a leading prefix, while Object.keys() lists every key. Instrumented O[key] reads at the JS boundary:
obj keys=Authorization,Content-Type,anthropic-beta,anthropic-version
O["Authorization"] = "Bearer …" ✓ (index 0)
O["Content-Type"] = undefined ✗ (index 1)
O["anthropic-beta"] = undefined ✗
O["anthropic-version"] = undefined ✗
obj keys=Accept,Content-Type,User-Agent,x-service-name,Authorization,anthropic-beta
O["Accept"] = "application/json…" ✓ (index 0)
O["Content-Type"] = "application/json" ✓ (index 1)
O["User-Agent"] = undefined ✗ (index 2)
O["x-service-name"]= undefined ✗
O["Authorization"] = undefined ✗ (no hyphen — content-independent)
O["anthropic-beta"]= undefined ✗
Only a leading prefix of each object's values survives (1–2 slots); everything after reads undefined, independent of key content (Authorization reads fine at index 0 in one object, undefined at index 2/3 in others). Object.keys() returns all keys, so the keys_array is intact — it's the value storage that desyncs.
Downstream impact: an authenticated request's headers (Content-Type, anthropic-beta, anthropic-version) silently become undefined, get filtered out by the serializer, and the request goes out with 2 of its headers → server rejects → the app's whole authenticated path fails.
Scope
Reproduces only at full-bundle scale. Every isolated reproduction of the exact construction passes identically to node — the axios mergeConfig/AxiosHeaders.concat caseless-merge path, Object.keys-iterated computed-key growth, hyphenated keys, class-instance receivers, forced GC over 15-key objects, and 200×-repeated shape-sharing across overlapping-prefix objects. So the trigger is a scale-only interaction — thousands of distinct object shapes churning through the generic property get/set, GC pressure, and/or the -Os codegen.
Suspects (perry-runtime/src/object/)
Value storage vs keys_array desync under shape-sharing / hidden-class transitions:
- the 1024-entry direct-mapped
FIELD_CACHE(field_get_set/get_field_by_name_tail.rs, keyed(keys_id, key_hash) → field_index) returning a slot valid for the shape but reading a stale/other object's value; OVERFLOW_FIELDS(values past the inline header slots) keyed on the object pointer, which the code comments note can be orphaned when GC recycles a pointer — a moved object would then readundefinedfor its overflow slots (matches "leading inline slots survive, rest lost");- the shape-transition cache / keys_array cloning-on-insert (
object/mod.rs).
The "only a leading prefix survives" signature points at the boundary between inline value slots (survive) and overflow/side-table storage (lost). I'm continuing with a runtime probe in js_object_get_field_by_name to distinguish wrong-field-index (cache) from empty-slot (overflow/growth). Will attach the probe trace.
Activity
proggeramlug commentedon Jul 5, 2026
Full diagnosis (runtime-probed on the 13 MB bundle)
Traced the value-desync mechanically with env-gated probes in the object read/write paths. Findings:
1. The values are written correctly. A probe in the index store (
js_object_set_field) shows the header values stored with a correct immediate readback (in == readback). So codegen and the store path are fine.2. The desynced object is a downstream object, not the one that stored. The object that later reads
undefinedhas the correct keys (Object.keysintact) but its inline value slots readraw = 0x7ffc…0001= cleanTAG_UNDEFINED— never written to that object. It adopted the shape's sharedkeys_array(shape/transition sharing) but its own value slots were never populated. Pointer-identity correlation between the store-object and the read-object is confounded by GC address reuse (a freed object's address is recycled), so "same pointer stored then read undefined" is not reliable evidence of same-object loss.3. Not a GC-evacuation loss. The copying collector copies the full object payload (
copy_nonoverlapping) and the object move-hook only migrates overflow/prototype side-tables — inline slots are preserved. So this is a write/copy-path bug (a merge/spread/shape-adopt that doesn't populate values), not GC corruption of a live slot.4.
field_count/keys_arrayinconsistency is involved. Some objects showkeys_array.length > field_count(e.g. 4 keys,field_count = 1).js_object_set_field(index store) does not bumpfield_count, whilejs_object_set_field_by_namedoes — sofield_count-bounded consumers, notablyjs_object_clone_with_extra(copies0..src_field_count), drop the over-field_countvalues on a spread{...obj}.Fixes attempted (each a full 40-min bundle rebuild to verify):
field_countbump injs_object_set_field(mirrorsset_field_by_name; all 1101 runtime tests pass) — no change to the bundle symptom.So the dominant residual is in the object merge/spread value-population path under bundle-scale shape sharing, not the transition cache (PR #6012 fixes a real but secondary contributor). It reproduces only at full-bundle scale — 16 faithful isolated reproductions (spread chains, forced GC, shape-sharing loops, the exact axios
mergeConfig/AxiosHeaders.concatshapes) all pass identically to node.Not minimally reproducible yet; needs either a scale reproduction or a GC/shape-subsystem-aware investigation. The probe scaffolding (read raw-slot classifier, index-store readback, transition-edge validation) is all env-gated behind
PERRY_HDR_PROBE=1and can be re-added quickly.fix(runtime): #6006 — also require exact target length on transition-…
style: fix duplicate #[inline] attribute (relocate #6006 helper above…
style: rustfmt the #6006 grown-target test assertion
fix(runtime): PerryTS#6006 — content-validate shape transition-cache …