You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
delete o.k is ignored by a single-owner property read: the deleted value is returned, not undefined #10822
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):
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.
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 allPtr<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.
{ a: 1, c: 3 } becomes New { class_name: "__AnonShape_…", args: [1, 3] } in HIR.
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.
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.
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.
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.
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.
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.
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.
Activity
proggeramlug commentedon Sep 20, 2026
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 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)
String(o.c)undefined3o.c + 1NaN4o.c === undefinedtruefalseo.creturned from a functionundefined3o.cpassed as a call argumentundefined3typeof o.cundefinednumber05class C { a = 1; c = 3 },new C()undefined3undefined10undefined1const k="c"; delete o[k]undefined3const 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)
"c" in ooreaches the bare-LocalGetarm → escapesObject.keys(o)JSON.stringify(o)delete o.c; o.c = 99; o.cObject.defineProperty(o, "c", {get})opassed as an argument → escapesObject.freeze(o)then writeObject.setPrototypeOf(o, {…})objs.push(o))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")— fineconst p = o; delete p.c— fine (theLetinit escapeso)deleteinside a closure — fine (capture escapes)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.fix(#10822): delete this.k in a constructor also needs a heap receiver
proggeramlug commentedon 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.fix(#10822): delete on a scalar-replaced receiver escapes it
test(#10822): regression tests for delete on a non-escaping receiver
fix(#10822): delete this.k in a constructor also needs a heap receiver
proggeramlug commentedon Sep 20, 2026
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
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 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.