Skip to content

gc: js_native_call_method reuses one copy of its rooted receiver across ~1200 lines of allocating probes #7528

Closed

Description

@proggeramlug

Found while closing #7498. Distinct from it: #7498's two faults are gone from
this reproducer's protected run, and this one remains. Present on both link
modes.

What

js_native_call_method does root its receiver — object_handle on the
first lines of the function. It then reads it out once:

let object_handle = root_scope.root_nanbox_f64(object);
...
let object = object_handle.get_nanbox_f64();
let jsval = JSValue::from_bits(object.to_bits());

and that local object (99 uses) and the derived jsval (32 uses) are reused
for the next ~1200 lines, across a dozen probes that allocate. A value read
out of a root and held in a register across a call is not rooted

(docs/src/internals/gc-rooting-invariant.md) — the root keeps the object
alive and the collector rewrites the slot, not the copy.

The measured deref is the closure-magic probe at native_call_method.rs:1041:

let raw_addr = if jsval.is_pointer() {
    crate::value::js_nanbox_get_pointer(object) as usize   // stale copy
} ...
if crate::value::addr_class::is_above_handle_band(raw_addr)
    && crate::closure::is_closure_ptr(raw_addr)            // dereferences it

Repro

On main (or on #7527's branch — unchanged by it), with the shipped witness:

PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=200 \
  ./out-of test-files/test_gap_gc_iterator_drain_rooting.ts
[gc-fromspace-protect] FAULT: signal 10
  array_from_spread_value + 2148     (js_closure_call0 — the bound @@iterator thunk)
  js_native_call_method + 5604
  main

and under lldb the faulting instruction is the magic read itself:

stop reason = EXC_BAD_ACCESS (code=2, address=0x25773a102fc)
frame #0: js_native_call_method + 6060
->  ldr    w8, [x28, #0xc]           ; CLOSURE_MAGIC, 0x434C4F53

5/5 under the quarantine. Symbolicate with PERRY_LINK_MAP=<path> — the perry
link passes -Wl,-no_exported_symbols, so atos and nm return nothing and
the handler's own backtrace prints bare offsets.

Observable consequence

This one is not latent. On macOS/arm64, test_gap_gc_iterator_drain_rooting
prints badLen 1 instead of badLen 0, 5/5, on PERRY_NO_AUTO_OPTIMIZE=1 —
i.e. one of 50 000 clones loses its tags array. (On main today it is worse:
TypeError: next is not a function, 5/5; #7527 removes that and leaves the
badLen 1.)

Knob bisect says the copying minor, not policy evacuation:

knob result
PERRY_GEN_GC=0 correct
PERRY_GC_SCAVENGE=0 correct
PERRY_WRITE_BARRIERS=0 correct
PERRY_GEN_GC_EVACUATE=0 still badLen 1

First thing to check — and what NOT to do

Do not patch the faulting line. That was tried on #7527's branch: re-reading
object_handle at the top of the raw_addr block made the fault move 800 bytes
further into the same function (+6060 → +6868, ldurb w8, [x19, #-0x8], the
GcHeader obj_type read). One arm of a 99-use function is half a fix, and the
half that is missing is the half that still crashes.

The shape that closes it is the one #7495 used for
dispatch_array_iterator_method: shadow the parameter with a reader so the
pre-collection address is not nameable at all —

let object = || object_handle.get_nanbox_f64();

— and rewrite the 99 object / 32 jsval uses through it. Mechanical, but it
is the single hottest dispatcher in the runtime and it deserves its own PR and
its own review rather than riding along on a spread fix.

Note the file is 1969 lines against the 2000-line cap
(scripts/check_file_size.sh), so the rewrite has ~30 lines of headroom; the
closure form is net-neutral, a comment block is not.

Acceptance

test_gap_gc_iterator_drain_rooting (already registered in
test-parity/gc_repsel_corpus.txt) is the witness: it must print badLen 0
and go clean under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=200 on both links. Check
PERRY_GC_DIAG=1 prints [gc-fromspace-protect] retired_set=#N before
believing a clean run — a run with zero copying minors protects nothing.

Activity

  1. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    I cannot reproduce this at current main (v0.5.1300), and I think the observable half is already fixed.

    The issue's repro is test_gap_gc_iterator_drain_rooting.ts, expected to fault
    under PERRY_GC_PROTECT_FROMSPACE=1 DEPTH=200 and to print badLen 1 (with
    TypeError: next is not a function on main). At main I get, on a
    perry-dev build of the full 5-package set:

    checksum: 1249975000 badLen 0 badVal 0        # correct, matches node
    protected DEPTH=200   x5  -> exit 0 (5/5 clean)
    protected DEPTH=800       -> exit 0
    protected DEPTH=800 + PERRY_GC_ZEAL=1 -> exit 0
    same again on a PERRY_GC_MOVING_LOOP_POLLS=1 build -> exit 0
    

    The instrument is live in these runs, not silently absent — PERRY_GC_DIAG=1
    prints seven [gc-fromspace-protect] mode=ProtectPages retired_set=#0…#4
    sets, 17 blocks each, up to 89 MB protected, alongside [gc-copy-minor]
    cycles moving ~10k objects. So this is a genuine negative, not a vacuous pass.

    The likely explanation is ordering: this issue was filed from a branch measured
    before #7516, #7527 and #7529 all landed. Those fixed 14 + 3 + several
    rooting sites on exactly the paths this witness exercises — including
    js_get_global_this_builtin_value, js_array_clone, js_async_step_chain
    and the array_from_spread_value walk. The next is not a function symptom
    is the classic downstream signature of any of them.

    The code shape this issue describes is still real, and still contradicts
    docs/src/internals/gc-rooting-invariant.md: js_native_call_method roots
    object, reads it out once, and reuses that copy (plus the derived jsval)
    across ~1160 lines and a dozen allocating probes. That is verifiable by
    reading. What I no longer have is a workload that demonstrates it, so any
    fix I write now is hardening against a latent shape, not a repair of a measured
    fault — and I will not claim otherwise.

    I have such a fix on fix/7528-native-call-method-receiver: the receiver
    becomes a closure (let object = || object_handle.get_nanbox_f64();), so the
    compiler proves every use re-reads rather than a human eyeballing 1160 lines.
    Byte-identical to node, 1761 runtime tests pass, protected runs clean — but
    clean on both arms, so the evidence is "does not regress", not "fixes".

    Leaving this open rather than closing it: if the shape is to be fixed, it
    should be on the strength of the invariant plus a witness that actually
    exercises the is_closure_ptr probe at native_call_method.rs:1041 with a
    receiver that has moved. Building that witness is the real task here. Anyone
    picking it up should re-check the repro first — it may need the specific
    allocation timing the pre-#7516 tree had.

  2. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    The acceptance criterion is now met, and it no longer discriminates — the source hazard is unchanged

    Measured on 8d27032c7 (release, PERRY_NO_AUTO_OPTIMIZE=1, macOS/arm64).

    The source is exactly as filed

    object/native_call_method.rs:723-732 is unchanged:

    let object_handle = root_scope.root_nanbox_f64(object);
    …
    let object = object_handle.get_nanbox_f64();
    let jsval = JSValue::from_bits(object.to_bits());

    92 lines below still name object, 30 name jsval, and the function still runs to the end of the file (1969 lines). The invariant the issue cites holds unchanged.

    But the witness no longer reproduces

    test_gap_gc_iterator_drain_rooting:

    arm badLen faults quarantine live?
    plain, 5 runs 0 5/5 — —
    the issue's own knobs (PROTECT_FROMSPACE=1 DEPTH=200), 5 runs 0 5/5 0/5 retired_set= 0/5 — protected NOTHING
    alloc-pressure route (HEAP_LIMIT=8 INCREMENTAL=0 CONSERVATIVE_STACK_SCAN=off) + quarantine, 5 runs 0 5/5 0/5 5/5, copied_objects up to 6
    PERRY_GC_FORCE_EVACUATE=1 + quarantine, 5 runs 0 5/5 0/5 0/5 — protected nothing
    PERRY_GC_ZEAL=1 + quarantine DEPTH=800, 5 runs 0 5/5 0/5 5/5

    Also zero TypeErrors on the plain arm, so the main-today symptom the issue records (TypeError: next is not a function, 5/5) is gone as well.

    ★ The acceptance criterion as written is now vacuous

    The issue says:

    it must print badLen 0 and go clean under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=200 on both links. Check PERRY_GC_DIAG=1 prints [gc-fromspace-protect] retired_set=#N before believing a clean run.

    That check fails today: those exact knobs print retired_set= zero times, so the arm the criterion names protects nothing. #7687 made allocation-point collections non-moving (the conservative-scan guard is now unconditional), and this workload's only collections were reaching the collector at an allocation point. The criterion needs a knob set that still produces a copying minor — the alloc-pressure route and zeal both do here, and both are clean.

    PERRY_GC_FORCE_EVACUATE=1 also prints no retired_set on this workload, which is worth noting separately: on its own it is not a way to force this witness onto a moving path.

    What I did not do, and why

    I did not perform the 92-use closure rewrite the issue prescribes. The change is mechanical but it is to the hottest dispatcher in the runtime, and right now there is no configuration in which the witness fails — so the rewrite would be unverifiable: both arms of any A/B would print badLen 0, which is the vacuous-A/B shape CLAUDE.md warns about. Landing it would be asserting a fix for something no measurement can currently distinguish.

    What the issue needs first is a witness that fails again. Two concrete candidates, both suggested by the numbers above: this workload only reaches copied_objects=6, so it is not stressing the dispatcher's probes at all; and the closure-magic probe at native_call_method.rs:1041 needs a receiver that survives a collection inside one of the dozen allocating probes between the read and the use. A workload with a larger live set driven through js_native_call_method under zeal (which does move — 43 799 objects on an unrelated probe here) is the shape most likely to restore it.

    The structural argument for the rewrite stands regardless; it is the evidence that has gone missing, not the reasoning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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