Skip to content

delete o.k is ignored by a single-owner property read: the deleted value is returned, not undefined #10822

Description

@proggeramlug

delete o.c is ignored by a property read, and the read returns the deleted value instead of undefined.

const o: Record<string, number> = { a: 1, c: 3 };
delete o.c;
console.log(String(o.c));
node:  undefined
perry: 3

Reproduces on an unmodified v0.5.1618 build (perry build + run, PERRY_NO_CACHE=1), and identically with PERRY_OBJECT_TOMBSTONES=0, so it is not the #9064 tombstone representation — the compacting delete has it too.

It is a silent wrong value, not a crash

o.c is not merely "still present": it evaluates to the value the property held before the delete. o.c === undefined is false, String(o.c) is "3". Nothing in the output says anything is wrong.

What makes it appear and disappear

The receiver must be provably single-owner and the property must be read essentially once. Adding a second observer of the object makes it correct again:

program node perry
const o = {a:i,c:i+2}; delete o.c; String(o.c) (module scope or inside a function) undefined 3
same, but inside a for loop, 1 or 5 iterations undefined wrong
same, but the object is also objs.push(o)-ed undefined undefined
same, but followed by Object.keys(o) / JSON.stringify(o) / several more reads of o.c undefined undefined

Object.keys(o) and JSON.stringify(o) are always right (a only), so the delete itself happens — only the specialized read misses it.

Loop form, which is how it was found (the checksum is off by exactly N):

let bad = 0;
for (let i = 0; i < 5; i++) {
  const o: Record<string, number> = { a: i, b: i + 1, c: i + 2, d: i + 3 };
  delete o.c;
  const v = o.c;
  if (v !== undefined) bad++;
}
console.log(bad);   // node: 0, perry: 5

Why this matters now

The object-model work (one fast path for property access) treats the single-owner Ptr<Shape> read — ~7 instructions, no hole check, no shape guard — as the target shape of the generic path. That path is the one that is wrong here: the generic 41-instruction path carries a TAG_HOLE compare and gets this right, and the fast path does not. Any plan that generalizes the fast path has to fix this first, or it generalizes the bug.

Found while measuring delete on an allocating workload for the delete-shape-transition lane.

https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ

Activity

  1. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Root cause: escape analysis + scalar replacement, not Ptr<Shape>

    It is not the proven Ptr<Shape> path. PERRY_OPT_REPORT=text on the exact three-line repro says:

      Ptr<Shape>          0 selected /    1 denied   (0% of 1 candidates)
          ptr-shape rule 5 (module-wide barrier)
          the module contains a shape barrier (`Object.defineProperty`, `delete`, …)
    

    ptr-shape rule 5 already disables all Ptr<Shape> promotion in any module containing a delete, so the ~7-instruction proven read is never selected here and is not involved.

    The faulty path is scalar replacement, which is faster still — it emits zero instructions for the read, because there is no object.

    1. { a: 1, c: 3 } becomes New { class_name: "__AnonShape_…", args: [1, 3] } in HIR.
    2. collectors/escape_check.rs decides whether the binding escapes. Expr::Delete(operand) had no arm of its own: it sat in the generic unary or-pattern (escape_check.rs:421 on main), which strips the delete and hands the inner PropertyGet to the arm at escape_check.rs:219. That arm sees a read of a declared field on a candidate and returns early — without recursing into object, so the bare LocalGet(o) is never visited and nothing escapes.
    3. stmt/let_stmt.rs:918 therefore scalar-replaces o: the heap object is never allocated, each observed field gets its own alloca, and ctx.locals[id] is a dummy alloca that is never populated.
    4. delete o.c still lowers (expr/instance_misc1.rs:769) to js_object_delete_field_value(<dummy slot>, "c"). The receiver is garbage, and the deliberate "pass the RAW receiver: delete (primitive).field must no-op to true" guard turns it into a silent nothing — which is why this is a wrong value and not a crash.
    5. o.c then loads the c alloca via expr/property_get.rs's ScalarObjectFieldGet arm, which still holds 3.

    So it is a stale slot, not a constant fold: the value comes from the field's stack slot, not from the initializer folded into the read. (delete o.c; o.c = 99; o.c correctly answers 99 — a fold would not update.)

    Why a second observer is correct

    Every "repair" in the table passes o somewhere as a value: Object.keys(o), JSON.stringify(o), objs.push(o), "c" in o. Each reaches escape_check.rs:381 — "LocalGet in any OTHER position = escape" — which escapes the candidate. The object is then heap-allocated, the runtime delete is real, and the generic read's TAG_HOLE compare answers undefined. A delete on its own was the one observer that never reached that arm.

    This is the removal sibling of the rule the read arm got in #10689 and the write arms got in #9024 / #9460.

    Scope

    Measured on the unmodified v0.5.1618 baseline, one fixture per row, each compiled and run alone (extra observers mask it).

    Wrong (12)

    # program node perry
    1 String(o.c) undefined 3
    2 o.c + 1 NaN 4
    3 o.c === undefined true false
    4 o.c returned from a function undefined 3
    5 o.c passed as a call argument undefined 3
    6 typeof o.c undefined number
    7 5-iteration loop, count of stale reads 0 5
    8 class C { a = 1; c = 3 }, new C() undefined 3
    9 12-field literal, delete the 10th undefined 10
    10 delete the first field undefined 1
    11 computed key: const k="c"; delete o[k] undefined 3
    12 const a=[1,2,3]; delete a[1]; a[1] undefined 2

    Row 12 is new — not in the original report. Non-escaping array literals are scalar-replaced by the same machinery (collectors/escape_arrays.rs) and had the identical defect.

    Rows 8 and 12 widen the report: it is not limited to object literals. Any non-escaping scalar-replacement candidate — object literal, new C(), array literal — is affected, and inline vs spill slot position makes no difference (rows 9 and 10), because scalar replacement has no slots in the object-model sense at all: it has one alloca per observed field.

    Correct, and why (8)

    program verdict reason
    "c" in o correct o reaches the bare-LocalGet arm → escapes
    Object.keys(o) correct same
    JSON.stringify(o) correct same
    delete o.c; o.c = 99; o.c correct the write stores into the same slot
    Object.defineProperty(o, "c", {get}) correct o passed as an argument → escapes
    Object.freeze(o) then write correct same
    Object.setPrototypeOf(o, {…}) correct same
    masking variants (2nd read / objs.push(o)) correct escape, as reported

    This resolves the Object.keys / JSON.stringify asymmetry: they are not "delete-aware paths that happen to be right". They are right because looking at the object at all is what makes the delete real. In a program where they are absent, the delete never happens.

    Sibling invalidating operations

    Tested on the unfixed tree; all already correct, all for the same structural reason (the receiver is passed as a value, so it escapes):

    • Object.defineProperty data → accessor — fine
    • Object.freeze / Object.seal — fine
    • Object.setPrototypeOf — fine
    • Reflect.deleteProperty(o, "c") — fine
    • alias binding const p = o; delete p.c — fine (the Let init escapes o)
    • delete inside a closure — fine (capture escapes)
    • nested receiver delete o.b.c — fine (o.b is a heap object)

    delete was the only invalidating operation that names its receiver without passing it anywhere, which is exactly why it alone slipped through.

    Fix

    Expr::Delete gets its own arm in all three escape analyses (escape_check.rs, escape_objects.rs, escape_arrays.rs), escaping a candidate named as the delete's receiver, then recursing as before.

    No cost to the proven path: Ptr<Shape> already refuses any module containing a delete, and an object that is not a delete target takes exactly the same decisions as before. PR to follow.

  2. added a commit that references this issue on Sep 20, 2026
  3. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Fix opened as #10832 (ready for review).

    It also covers two shapes not in the original report: const a=[1,2,3]; delete a[1]; a[1] answering 2 (non-escaping array literals are scalar-replaced by the same machinery), and delete this.k inside a constructor.

    Measured cost: none. /root/ops4/f1..f4 and a scalar-replaced class loop compile to identical machine code before and after; only programs containing a delete change.

  4. added 3 commits that reference this issue on Sep 20, 2026
  5. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in v0.5.1623 (merge train 244, #10844 / 91cc563573), by #10832.

    Root cause

    Not Ptr<Shape>. PERRY_OPT_REPORT=text on the repro reports Ptr<Shape> 0 selected / 1 denied — ptr-shape rule 5 already disables promotion in any module containing a delete. The faulty path is scalar replacement, which is faster still: it emits zero instructions for the read, because there is no object.

    Expr::Delete had no arm of its own in collectors/escape_check.rs; it sat in the generic unary or-pattern, which strips the delete and hands the inner PropertyGet to the arm that sees a read of a declared field on a candidate and returns early without recursing into object. The bare LocalGet(o) was therefore never visited and the receiver never escaped. let_stmt.rs scalar-replaced o, the delete still emitted js_object_delete_field_value(<dummy slot>, "c"), and that call's deliberate "a delete on a primitive must no-op to true" guard turned the garbage receiver into a silent nothing — which is why this was a wrong value and not a crash.

    This is why every workaround in the report — Object.keys(o), JSON.stringify(o), objs.push(o), "c" in o — repaired it: each passes o somewhere as a value and so reaches "LocalGet in any other position = escape". A delete was the one observer that names its receiver without passing it anywhere.

    Fix

    Expr::Delete gets its own arm in all three escape analyses (escape_check.rs, escape_objects.rs, escape_arrays.rs): escape a candidate named as the delete's receiver, then recurse as before.

    Verified on the landed artifacts

    const o: Record<string, number> = { a: 1, c: 3 };
    delete o.c;
    console.log(String(o.c));        // undefined  (was: 3)
    console.log(o.c === undefined);  // true       (was: false)
    const p: Record<string, number> = { a: 1, c: 3 };
    delete p.c; p.c = 99;
    console.log(String(p.c));        // 99
    

    Byte-identical to node --experimental-strip-types. The third line confirms the original diagnosis in the report — a stale slot, not a constant fold, since a fold would not answer 99.

    Regression coverage: crates/perry/tests/scalar_replaced_delete_10822.rs (ran green in the train at rc=0) and test-files/test_parity_delete_shape_transition.ts.

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