Skip to content

gc: scalar-replaced object/array locals holding heap values are not precise roots #6968

Description

@proggeramlug

Found while fixing #6951. Distinct defect, same symptom class.

With the conservative native-stack scan disabled, a heap value stored into a scalar-replaced object or array local is swept out from under the alloca that holds it. The alloca has no shadow-slot binding, so it is not a precise root.

Repro

let sink: unknown[] = [];
function churn(n: number): number {
  let acc = 0;
  for (let i = 0; i < n; i++) {
    sink.push({ i: i, s: "x" + (i & 255), a: [i, i + 1] });
    if (sink.length > 4096) { acc = (acc + sink.length) | 0; sink = []; }
  }
  return acc | 0;
}
function fresh(k: number): string { return "f" + k + "-" + (k * 7); }
const N = 420000;
// Repeat 6x -- the failure is a use-after-free, so the freed block has to be
// recycled before it shows. A single statement passes.
{ const o = { a: fresh(0), b: churn(N) }; console.log(o.a, o.b); }
{ const o = { a: fresh(1), b: churn(N) }; console.log(o.a, o.b); }
{ const o = { a: fresh(2), b: churn(N) }; console.log(o.a, o.b); }
{ const o = { a: fresh(3), b: churn(N) }; console.log(o.a, o.b); }
{ const o = { a: fresh(4), b: churn(N) }; console.log(o.a, o.b); }
{ const o = { a: fresh(5), b: churn(N) }; console.log(o.a, o.b); }
$ PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_HEAP_LIMIT=8 ./repro
f0-0 417894
f1-7 421991
f2-14 417894
 421991        <- o.a is empty
 417894
 421991

The array-literal form is identical: const a = [fresh(k), churn(N)]; console.log(a[0], a[1]);.

Why

There is no object. The literal is scalar-replaced, so o.a / o.b are plain entry-block allocas:

%r13 = call double @..._fresh__spec_i32(i32 0)
store double %r13, ptr %r10                   ; <-- o.a, a bare alloca
%r16 = call double @..._churn(double %r15)    ; collects; %r10's string is swept
store double %r16, ptr %r11                   ; <-- o.b

%r10 holds a heap string and is bound to no shadow slot, so the shadow-stack root walk never sees it. #6951's object-literal fix does not apply — that path roots the object handle, and here there is no handle.

Scope

Any scalar-replaced object/array local whose replaced slot can hold a heap value (string, object, closure) and is live across an allocating call. Numeric-only scalar replacement is unaffected.

Likely fix: the scalar-replacement pass must reserve a shadow slot (and emit js_shadow_slot_bind) for each replaced slot that is not proven non-pointer — the same treatment an ordinary pointer-typed local already gets.

Detection

Invisible today: gc_check_trigger forces a conservative native-stack scan on both automatic arms, which finds the alloca. Production resolves conservative_stack_scan_mode() to Auto -> SkipDisabled. The matrix's cons_scan_off arm (scripts/gc_repsel_matrix.sh) is the configuration that sees it, and after #6951 that arm is green, so a corpus member with this shape would now catch it.

Related: #6951, #6950, #6942.

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