Skip to content

Large-bundle: object property values read undefined past a leading prefix (value/keys_array desync at scale) #6006

Closed

Description

@proggeramlug

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 read undefined for 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

  1. proggeramlug commented on Jul 5, 2026

    @proggeramlug
    ContributorAuthor

    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 undefined has the correct keys (Object.keys intact) but its inline value slots read raw = 0x7ffc…0001 = clean TAG_UNDEFINED — never written to that object. It adopted the shape's shared keys_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_array inconsistency is involved. Some objects show keys_array.length > field_count (e.g. 4 keys, field_count = 1). js_object_set_field (index store) does not bump field_count, while js_object_set_field_by_name does — so field_count-bounded consumers, notably js_object_clone_with_extra (copies 0..src_field_count), drop the over-field_count values on a spread {...obj}.

    Fixes attempted (each a full 40-min bundle rebuild to verify):

    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.concat shapes) 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=1 and can be re-added quickly.

  2. added 3 commits that reference this issue on Jul 5, 2026
  3. added a commit that references this issue on Jul 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions