Skip to content

Tombstone deletes lose a live object's keys array under evacuating GC — Object.keys() returns empty, fields read NaN (main, gc-stress red) #9200

Description

@proggeramlug

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.

Activity

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