Summary
On current main (84185b5656), an object that has been through a tombstone delete can lose its entire keys array across an evacuating collection. Object.keys() returns the empty string and a previously-live field reads NaN.
This is mine — PERRY_OBJECT_TOMBSTONES=0 makes it pass, so the tombstone delete path shipped in #9038 owns it. Filing rather than quietly fixing because it is on main, it is a silent wrong-answer bug, and gc-stress is red for every PR because of it.
Reproducer
Fixture already in the tree: test-files/test_gap_repsel_pshape_tower_delete.ts.
perry compile test-files/test_gap_repsel_pshape_tower_delete.ts -o tower
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1 ./tower
Deterministic — three runs byte-identical to each other, and byte-identical across an independent rebuild.
| line |
node 26.5.1 |
perry |
after2: |
103,206,309,NaN |
103,NaN,309,NaN |
keys3: |
b|c |
(empty) |
keys3 is Object.keys(rows[1]).join("|") after a second delete plus allocation churn. An empty result means the receiver's keys array is gone, not that a key was removed — and the 206 → NaN on the same object is the matching field read.
Attribution matrix
Each row is the same binary, only the environment differs:
| environment |
result |
| default (no env) |
PASS |
PERRY_GC_HEAP_LIMIT=8 alone |
PASS |
PERRY_GC_FORCE_EVACUATE=1 alone |
PASS |
PERRY_GC_HEAP_LIMIT=8 + PERRY_GC_FORCE_EVACUATE=1 |
FAIL |
PERRY_GC_HEAP_LIMIT=8 + PERRY_GC_VERIFY_EVACUATION=1 |
PASS |
PERRY_GC_HEAP_LIMIT=8 + FORCE_EVACUATE + VERIFY_EVACUATION |
FAIL |
PERRY_GC_HEAP_LIMIT=8 + FORCE_EVACUATE + VERIFY_EVACUATION + PERRY_OBJECT_TOMBSTONES=0 |
PASS |
Two things follow. The trigger needs both heap pressure and a relocating minor — neither alone reproduces, which is why the shipped default does not show it and why only the force_verify/evac_minor family of arms is red. And the tombstone kill switch clears it, which localises the defect to the delete path rather than to the collector.
PERRY_GC_VERIFY_EVACUATION=1 does not fire, so this is not a slot the evacuation verifier covers — the stale reference is somewhere the verifier does not walk.
Where I would look
The shape is "a raw reference held across a relocating collection" (#6993's family). The delete path clones the keys array during squeeze and republishes shape facts; the suspect set is any raw keys / ObjectHeader pointer live across an allocation in object/delete_rest.rs's squeeze_holes_and_delete and the publish_object_shape_holes call ordering that #9110 already had to correct once (shape_drop → publish_object_shape_holes → set_object_live_slot_count).
Note the symptom is the keys array being lost, not a wrong slot index — so a stale keys pointer surviving into the shape descriptor, or an object whose keys edge is not traced while it carries holes, fits better than an off-by-one in the squeeze.
Why this matters beyond the red gate
Silent, not a crash. An object quietly loses its properties, and the program continues with undefined/NaN — the same failure class as #9192. It needs both heap pressure and relocation today, but the moving young-gen scavenge is the default collector since #7019, so the distance between this configuration and a real user's is smaller than the env vars make it look.
Not fixed here
Recording the reproducer, the attribution and the deterministic diff. I am taking the fix.
Summary
On current
main(84185b5656), an object that has been through a tombstone delete can lose its entire keys array across an evacuating collection.Object.keys()returns the empty string and a previously-live field readsNaN.This is mine —
PERRY_OBJECT_TOMBSTONES=0makes it pass, so the tombstone delete path shipped in #9038 owns it. Filing rather than quietly fixing because it is onmain, it is a silent wrong-answer bug, andgc-stressis red for every PR because of it.Reproducer
Fixture already in the tree:
test-files/test_gap_repsel_pshape_tower_delete.ts.Deterministic — three runs byte-identical to each other, and byte-identical across an independent rebuild.
after2:103,206,309,NaN103,NaN,309,NaNkeys3:b|ckeys3isObject.keys(rows[1]).join("|")after a second delete plus allocation churn. An empty result means the receiver's keys array is gone, not that a key was removed — and the206 → NaNon the same object is the matching field read.Attribution matrix
Each row is the same binary, only the environment differs:
PERRY_GC_HEAP_LIMIT=8alonePERRY_GC_FORCE_EVACUATE=1alonePERRY_GC_HEAP_LIMIT=8+PERRY_GC_FORCE_EVACUATE=1PERRY_GC_HEAP_LIMIT=8+PERRY_GC_VERIFY_EVACUATION=1PERRY_GC_HEAP_LIMIT=8+FORCE_EVACUATE+VERIFY_EVACUATIONPERRY_GC_HEAP_LIMIT=8+FORCE_EVACUATE+VERIFY_EVACUATION+PERRY_OBJECT_TOMBSTONES=0Two things follow. The trigger needs both heap pressure and a relocating minor — neither alone reproduces, which is why the shipped default does not show it and why only the
force_verify/evac_minorfamily of arms is red. And the tombstone kill switch clears it, which localises the defect to the delete path rather than to the collector.PERRY_GC_VERIFY_EVACUATION=1does not fire, so this is not a slot the evacuation verifier covers — the stale reference is somewhere the verifier does not walk.Where I would look
The shape is "a raw reference held across a relocating collection" (#6993's family). The delete path clones the keys array during squeeze and republishes shape facts; the suspect set is any raw
keys/ObjectHeaderpointer live across an allocation inobject/delete_rest.rs'ssqueeze_holes_and_deleteand thepublish_object_shape_holescall ordering that #9110 already had to correct once (shape_drop → publish_object_shape_holes → set_object_live_slot_count).Note the symptom is the keys array being lost, not a wrong slot index — so a stale keys pointer surviving into the shape descriptor, or an object whose keys edge is not traced while it carries holes, fits better than an off-by-one in the squeeze.
Why this matters beyond the red gate
Silent, not a crash. An object quietly loses its properties, and the program continues with
undefined/NaN— the same failure class as #9192. It needs both heap pressure and relocation today, but the moving young-gen scavenge is the default collector since #7019, so the distance between this configuration and a real user's is smaller than the env vars make it look.Not fixed here
Recording the reproducer, the attribution and the deterministic diff. I am taking the fix.