Repository navigation
delete o.k is ignored by a single-owner property read: the deleted value is returned, not undefined #10822
Description
Activity
Root cause: escape analysis + scalar replacement, not
Ptr<Shape>It is not the proven
Ptr<Shape>path.PERRY_OPT_REPORT=texton 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 adelete, 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.
{ a: 1, c: 3 }becomesNew { class_name: "__AnonShape_…", args: [1, 3] }in HIR.collectors/escape_check.rsdecides whether the binding escapes.Expr::Delete(operand)had no arm of its own: it sat in the generic unary or-pattern (escape_check.rs:421onmain), which strips thedeleteand hands the innerPropertyGetto the arm atescape_check.rs:219. That arm sees a read of a declared field on a candidate and returns early — without recursing intoobject, so the bareLocalGet(o)is never visited and nothing escapes.stmt/let_stmt.rs:918therefore scalar-replaceso: the heap object is never allocated, each observed field gets its ownalloca, andctx.locals[id]is a dummy alloca that is never populated.delete o.cstill lowers (expr/instance_misc1.rs:769) tojs_object_delete_field_value(<dummy slot>, "c"). The receiver is garbage, and the deliberate "pass the RAW receiver:delete (primitive).fieldmust no-op totrue" guard turns it into a silent nothing — which is why this is a wrong value and not a crash.o.cthen loads thecalloca viaexpr/property_get.rs'sScalarObjectFieldGetarm, which still holds3.
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.ccorrectly answers99— a fold would not update.)Why a second observer is correct
Every "repair" in the table passes
osomewhere as a value:Object.keys(o),JSON.stringify(o),objs.push(o),"c" in o. Each reachesescape_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'sTAG_HOLEcompare answersundefined. Adeleteon 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)undefined32 o.c + 1NaN43 o.c === undefinedtruefalse4 o.creturned from a functionundefined35 o.cpassed as a call argumentundefined36 typeof o.cundefinednumber7 5-iteration loop, count of stale reads 058 class C { a = 1; c = 3 },new C()undefined39 12-field literal, delete the 10th undefined1010 delete the first field undefined111 computed key: const k="c"; delete o[k]undefined312 const a=[1,2,3]; delete a[1]; a[1]undefined2Row 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 ocorrect oreaches the bare-LocalGetarm → escapesObject.keys(o)correct same JSON.stringify(o)correct same delete o.c; o.c = 99; o.ccorrect the write stores into the same slot Object.defineProperty(o, "c", {get})correct opassed as an argument → escapesObject.freeze(o)then writecorrect same Object.setPrototypeOf(o, {…})correct same masking variants (2nd read / objs.push(o))correct escape, as reported This resolves the
Object.keys/JSON.stringifyasymmetry: 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.definePropertydata → accessor — fineObject.freeze/Object.seal— fineObject.setPrototypeOf— fineReflect.deleteProperty(o, "c")— fine- alias binding
const p = o; delete p.c— fine (theLetinit escapeso) deleteinside a closure — fine (capture escapes)- nested receiver
delete o.b.c— fine (o.bis a heap object)
deletewas the only invalidating operation that names its receiver without passing it anywhere, which is exactly why it alone slipped through.Fix
Expr::Deletegets 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 adelete, and an object that is not adeletetarget takes exactly the same decisions as before. PR to follow.- added a commit that references this issue
on Sep 20, 2026 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]answering2(non-escaping array literals are scalar-replaced by the same machinery), anddelete this.kinside a constructor.Measured cost: none.
/root/ops4/f1..f4and a scalar-replaced class loop compile to identical machine code before and after; only programs containing adeletechange.Fixed in v0.5.1623 (merge train 244, #10844 /
91cc563573), by #10832.Root cause
Not
Ptr<Shape>.PERRY_OPT_REPORT=texton the repro reportsPtr<Shape> 0 selected / 1 denied— ptr-shape rule 5 already disables promotion in any module containing adelete. The faulty path is scalar replacement, which is faster still: it emits zero instructions for the read, because there is no object.Expr::Deletehad no arm of its own incollectors/escape_check.rs; it sat in the generic unary or-pattern, which strips thedeleteand hands the innerPropertyGetto the arm that sees a read of a declared field on a candidate and returns early without recursing intoobject. The bareLocalGet(o)was therefore never visited and the receiver never escaped.let_stmt.rsscalar-replacedo, the delete still emittedjs_object_delete_field_value(<dummy slot>, "c"), and that call's deliberate "adeleteon a primitive must no-op totrue" 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 passesosomewhere as a value and so reaches "LocalGet in any other position = escape". Adeletewas the one observer that names its receiver without passing it anywhere.Fix
Expr::Deletegets 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)); // 99Byte-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 answer99.Regression coverage:
crates/perry/tests/scalar_replaced_delete_10822.rs(ran green in the train atrc=0) andtest-files/test_parity_delete_shape_transition.ts.
delete o.cis ignored by a property read, and the read returns the deleted value instead ofundefined.Reproduces on an unmodified
v0.5.1618build (perry build+ run,PERRY_NO_CACHE=1), and identically withPERRY_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.cis not merely "still present": it evaluates to the value the property held before thedelete.o.c === undefinedisfalse,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:
const o = {a:i,c:i+2}; delete o.c; String(o.c)(module scope or inside a function)undefined3forloop, 1 or 5 iterationsundefinedobjs.push(o)-edundefinedundefinedObject.keys(o)/JSON.stringify(o)/ several more reads ofo.cundefinedundefinedObject.keys(o)andJSON.stringify(o)are always right (aonly), 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):
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 aTAG_HOLEcompare 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
deleteon an allocating workload for the delete-shape-transition lane.https://claude.ai/code/session_01YaNfLEjMRdhCLtk3SB5MdJ