Found while migrating the computed read/write modules onto the Layer 1 rooting API (slice 4 of #7615).
Two arms of crates/perry-codegen/src/expr/index_set.rs, one bug each, both in
the receiver's window.
1. The polymorphic fallback guards NOTHING
The last-resort Expr::IndexSet arm — reached when nothing about the receiver
or the key is statically known, which is when both are heap values by default:
let obj_box = lower_expr(ctx, object)?;
let idx_box = lower_expr(ctx, index)?;
let (val_double, _) = lower_value_for_dynamic_index_set(ctx, value, ...)?; // <-- user code
let obj_bits = ctx.block().bitcast_double_to_i64(&obj_box); // <-- stale
Both registers are then consumed across a four-block dispatch tree:
js_is_symbol, then js_object_set_symbol_property(obj_box, idx_box, val) on
the symbol arm, unbox_str_handle(idx_box) + the by-name setter on the string
arm, and js_typed_feedback_object_set_index_polymorphic(obj_handle, idx_box, val)
on the numeric arm. An evacuating minor inside the RHS lands the write on a
stale object under a stale key.
It is the most exposed arm in the file and the only one with no guard at all.
const m: any = {};
m[k] = mk(); // k: any, mk() allocates
2. The dynamic-string-key arm derives the receiver's window from value alone
let obj_box = lower_expr(ctx, object)?;
let recv_guard = guard_store_operand(ctx, object, &obj_box, value); // window := value ONLY
let key_box = lower_expr(ctx, index)?; // <-- but the receiver is live across THIS too
let key_guard = guard_store_operand(ctx, index, &key_box, value);
This is the half-measure #7201 named in prose and then did not apply here:
Deriving it from the value alone — which is what every caller did before
#7201 — leaves o[f()] = 1 unguarded, because the literal 1 cannot collect
while f() obviously can.
guard_store_operand's two-argument form structurally cannot say "the window is
everything after this operand"; only guard_store_operand_across can, and this
call site used the wrong one. So:
const o: Record<string, number> = {};
o[allocatingKey()] = 1; // receiver unguarded across allocatingKey()
Status
Both fixed in the slice-4 PR by making each arm one
rooting::with_operands_rooted_across group over [object] / [object, index]
with [value] as the caller-controlled step. The point is not that the flag is
now right, it is that there is no flag: with_operands_rooted_window computes
each operand's collects as across_collects || any_may_trigger_gc(exprs[i+1..]),
so "the receiver is live across everything after it" is a property of the list
rather than a thing the author has to remember.
The same arm's two nested guards also collapse to one group, which retires the
release-inner-to-outer obligation (temp_root_truncate is a stack CUT, so
releasing the receiver first silently dropped the key's slot).
Found while migrating the computed read/write modules onto the Layer 1 rooting API (slice 4 of #7615).
Two arms of
crates/perry-codegen/src/expr/index_set.rs, one bug each, both inthe receiver's window.
1. The polymorphic fallback guards NOTHING
The last-resort
Expr::IndexSetarm — reached when nothing about the receiveror the key is statically known, which is when both are heap values by default:
Both registers are then consumed across a four-block dispatch tree:
js_is_symbol, thenjs_object_set_symbol_property(obj_box, idx_box, val)onthe symbol arm,
unbox_str_handle(idx_box)+ the by-name setter on the stringarm, and
js_typed_feedback_object_set_index_polymorphic(obj_handle, idx_box, val)on the numeric arm. An evacuating minor inside the RHS lands the write on a
stale object under a stale key.
It is the most exposed arm in the file and the only one with no guard at all.
2. The dynamic-string-key arm derives the receiver's window from
valuealoneThis is the half-measure #7201 named in prose and then did not apply here:
guard_store_operand's two-argument form structurally cannot say "the window iseverything after this operand"; only
guard_store_operand_acrosscan, and thiscall site used the wrong one. So:
Status
Both fixed in the slice-4 PR by making each arm one
rooting::with_operands_rooted_acrossgroup over[object]/[object, index]with
[value]as the caller-controlled step. The point is not that the flag isnow right, it is that there is no flag:
with_operands_rooted_windowcomputeseach operand's
collectsasacross_collects || any_may_trigger_gc(exprs[i+1..]),so "the receiver is live across everything after it" is a property of the list
rather than a thing the author has to remember.
The same arm's two nested guards also collapse to one group, which retires the
release-inner-to-outer obligation (
temp_root_truncateis a stack CUT, soreleasing the receiver first silently dropped the key's slot).