Repository navigation
Copying minor loses ObjectHeader::keys_array on large/infrequent cycles — pre-existing, unmasked by #7432; blocks the public baseline #7472
Description
perry's default configuration segfaults on benchmarks/json_polyglot/bench_field_access.ts. It is deterministic (3/3), on main since ~13:00 today, and it blocks regenerating the public benchmark artifact — run_public_baseline.sh completes three of four legs and then fails the cross-runtime checksum gate with "no checksum row for 'perry'", because the default perry (gen-gc + lazy tape) variant produced no successful runs.
Repro
./target/release/perry benchmarks/json_polyglot/bench_field_access.ts -o /tmp/fa
/tmp/fa # exit 139, no output
Bisect
git bisect over the 46 commits merged today, testing exit status:
first bad commit: 77fa4c5c4 gc: adaptive tenuring + young-scoped scavenge cap (#7432)
Confirmed by hand at both ends — parent 0402a05af exits 0 three times out of three; 77fa4c5c4 and today's tip both exit 139 three out of three.
It is NOT the tenuring threshold
The headline change is ruled out. Pinning tenuring_survivals() to the pre-#7432 constant 4 still crashes 3/3. Flooring the survival-rate lock at 2 instead of 1 also still crashes. So neither adaptive tenuring nor promote-on-first-copy is the mechanism.
What it is: cycle size
The remaining part of #7432 is the young-scoped scavenge cap, which changed the cadence from degenerate once-per-1 MB-block to once per ~16 MB of young-gen occupancy. The effect on this workload:
| copying minors | objects copied per cycle | |
|---|---|---|
parent 0402a05af |
592 | ~123k |
tip 351742d30 |
4 | 228,425 (17.5 MB) in one cycle |
PERRY_GC_DIAG on the failing run shows the last two cycles as
copied_objects=228425 copied_bytes=17587656 tenuring_survivals=4
copied_objects=0 copied_bytes=0 tenuring_survivals=1
<segfault>
A cycle copying 228k objects, then one copying zero, then the fault.
Sweeping the cap confirms the size dependence — PERRY_GC_SCAVENGE_NURSERY_MB:
1 MB → 139 2 MB → 139 4 MB → 139 8 MB → 139 16 MB → 139 32 MB → 0
At 32 MB no minor fires in the vulnerable window and the program completes.
The fault
EXC_BAD_ACCESS (code=1, address=0x7ffc000000000014)
-> ldr w2, [x23, #0x4]
add x1, x23, #0x14
0x7FFC_0000_0000_0000 is the NaN-box immediate tag base; +0x14 is the header-payload offset. So a value that should be a heap object is an immediate (undefined/null/bool) and is being dereferenced as a StringHeader/ArrayHeader — length at +4, data at +0x14.
Both preconditions are required
default (gen-gc + lazy tape) → 139
PERRY_GEN_GC=0 → 0
PERRY_JSON_TAPE=0 → 0
both off → 0
Mark-sweep traces everything, so a missed old→young edge would not bite there — consistent with the copying minor being implicated. LazyArrayHeader holds four raw heap pointers (blob_str, materialized, materialized_elements, materialized_bitmap); they have a GcRewriteDescriptorKind::LazyArray rewrite descriptor in gc/layout.rs, and json_tape.rs registers no scanner of its own. That is where I would look first, at the scale the new cadence produces.
Note on the benchmark's checksum
Unrelated but worth recording: the field_access checksum is not stable run-to-run on the parent build (2552986845, 2552987326, 2552987326), while today's PERRY_JSON_TAPE=0 is stable at 2552986400. A cross-runtime checksum gate over a value that drifts is a weak gate; I did not chase this.
Impact
Default-configuration segfault on a JSON workload, and the public benchmark artifact cannot be regenerated until it is fixed — the gate correctly refuses to publish a comparison in which perry has no row.
Activity
proggeramlug commentedon Aug 5, 2026
Correcting the experimental record above, and adding a build-methodology warning that anyone reproducing this will hit.
Three of my "still crashes" results were run against a stale runtime archive, so they were not evidence of anything.
target/release/libperry_runtime.awas a day older thantarget/release/perry, and — the part that actually bites — the compile linkedi.e. the auto-optimize path builds its own feature-stripped runtime and links that, overriding
PERRY_RUNTIME_DIR. A runtime-side patch then looks like a no-op and both arms of an A/B behave identically. Reproducing anything here needsPERRY_NO_AUTO_OPTIMIZE=1, atouchon the edited file, and an mtime check that the.aactually moved.Re-run with that discipline, one result survives and is now trustworthy:
The two earlier claims I withdraw as untested:
tenuring_survivals()to 4 still crashes"Both need re-running under the above discipline before anyone relies on them. I would not assume the tenuring threshold is exonerated on my say-so.
What still stands
Everything in the original report that came from unmodified builds is unaffected, because those were the shipped artifacts rather than my patches:
77fa4c5c4, with parent0402a05afclean 3/3 and the commit itself 139 3/3PERRY_GC_SCAVENGE_NURSERY_MBsweep (crashes 1–16 MB, passes at 32 MB)PERRY_GEN_GC=0orPERRY_JSON_TAPE=0each avoid it)Practical note
A full
git revertof #7432 does not apply cleanly — it conflicts ingc/mod.rsbecause later work (#7443, #7449) built on it. So mitigating by revert is not a one-liner either.proggeramlug commentedon Aug 5, 2026
Crash site identified. With
--debug-symbols:So this is JSON stringify, not the tape parse — and
0x7FFC_…is the NaN-box immediate tag base, so a field that must hold a heap pointer holds an immediate.The unchecked dereference
json/stringify.rs, insidestringify_object_inner:keys_arrayis dereferenced behind a null check only. A value that is non-null but not a pointer — which is exactly what a NaN-boxed immediate is — passes that guard and faults. Note the sibling path a few lines below usesobject_keys_array_checked(obj), so a validated accessor already exists; this call site does not use it.Why I think the guard is the symptom, not the bug
ObjectHeader::keys_arrayis a raw pointer to a GC-managed array. For it to read back as an immediate, something must have written garbage into it or failed to rewrite it. The two shapes worth checking first:A missing old→young remembered-set edge. If the object is promoted to old-gen while its
keys_arrayis still young, the copying minor will not trace that edge unless the write barrier recorded it — the array is then moved or reclaimed withoutobj.keys_arraybeing rewritten. This fits every observation:PERRY_GEN_GC=0avoids it (mark-sweep traces everything, so a missing remembered-set entry cannot bite), and gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) #7432 changes exactly what ends up in old-gen and when.A
keys_arrayslot not covered by the object's rewrite descriptor at the sizes the new cadence produces.Tightening the call site to
object_keys_array_checkedwould stop the fault but would convert a lost-object bug into silently wrong JSON output, so it should not be the fix on its own.Corrected sweep, run properly
Re-run with
PERRY_NO_AUTO_OPTIMIZE=1and a verified-fresh archive. The relationship is not monotonic in the cap, so my earlier "passes at 32 MB" was not the whole picture:PERRY_GC_SCAVENGE_NURSERY_MBCrashes at 6/4/2 minors, passes at 48/28. It tracks how few collections happen, not cycle size directly (41k objects crashes, 66k passes) — consistent with "a minor has to land in a particular window", not with a size limit.
Also ruled out, each with a verified-fresh archive
tenuring_survivals()to the pre-gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) #7432 constant4→ still 139 (3/3)So none of #7432's three headline levers is individually responsible, which strengthens the "it changed what is in old-gen when, exposing a latent barrier/rewrite gap" reading over "one of its dials is wrong".
proggeramlug commentedon Aug 5, 2026
This bug is PRE-EXISTING. #7432 exposed it; it did not introduce it. Do not revert #7432.
The bisect was technically correct —
77fa4c5c4is the first commit where the default configuration crashes — but the defect is older. The parent commit crashes too, as soon as its scavenge cadence is made non-degenerate:0402a05afPERRY_GC_SCAVENGE_NURSERY_MB=16(its default)PERRY_GC_SCAVENGE_NURSERY_MB=32The old cadence was the mask. #7432's own commit message describes it: comparing the cap against
arena_total_bytes()meant that once old-gen crossed 16 MB, every fresh 1 MB Eden block re-crossed the trigger, degenerating the cadence to once-per-block. At 592 tiny collections the vulnerable window never opens. #7432 fixed that cadence — correctly — and the latent bug became reachable at default settings.Old-gen is not involved either
Pinning
tenuring_survivals()to 255 so nothing ever promotes (promoted_objects=0, verified viaPERRY_GC_DIAG) still crashes 3/3. So my earlier missing-old→young-remembered-set hypothesis is wrong, and I withdraw it. The whole thing happens inside the young generation: the copying minor moves the object and/or itskeys_arraywithin Eden/survivor space andObjectHeader::keys_arraycomes back holding a NaN-box immediate.Where that leaves it
The defect is in the copying minor's rewriting of
ObjectHeader::keys_array(or in whether the holding object is scanned at all) when a cycle is large and infrequent. It reproduces on any commit — including well before today — given a nursery cap that produces few, large minors.Two consequences worth stating plainly:
gc/mod.rsfrom gc: old-generation hole free list — swept holes become reusable capacity (#7437) #7443/arena: recycled-block pool — block round-trips through the allocator were the tree.ts RSS term (#7438) #7449).PERRY_GC_SCAVENGE_NURSERY_MB— or simply one large-Eden arm — would have. That is the coverage gap this exposes, independent of the fix.Reproducer, independent of today's merges
[-]perry's default config segfaults on json_polyglot/field_access; blocks the public baseline (bisected to #7432)[/-][+]Copying minor loses ObjectHeader::keys_array on large/infrequent cycles — pre-existing, unmasked by #7432; blocks the public baseline[/+]fix(json): validate the untagged key pointer in stringify_object_inner (
docs(engine-plan): 2026-08-06 ticket sync and repsel stack position (#…