Skip to content

index_set: the polymorphic o[k]=v fallback roots neither operand, and the string-key arm derives the receiver window from value alone (#7201 residue) #7639

Description

@proggeramlug

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).

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