Repository navigation
gc: js_native_call_method reuses one copy of its rooted receiver across ~1200 lines of allocating probes #7528
Description
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 itRepro
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
docs: name the residual protected-run fault as #7528
docs: name the residual protected-run fault as #7528
fix(gc): root the inlined spread's `[Symbol.iterator]` walk (#7498) (#…
proggeramlug commentedon Aug 6, 2026
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 faultunder
PERRY_GC_PROTECT_FROMSPACE=1 DEPTH=200and to printbadLen 1(withTypeError: next is not a functiononmain). AtmainI get, on aperry-devbuild of the full 5-package set:The instrument is live in these runs, not silently absent —
PERRY_GC_DIAG=1prints seven
[gc-fromspace-protect] mode=ProtectPages retired_set=#0…#4sets, 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_chainand the
array_from_spread_valuewalk. Thenext is not a functionsymptomis 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_methodrootsobject, reads it out once, and reuses that copy (plus the derivedjsval)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 receiverbecomes a closure (
let object = || object_handle.get_nanbox_f64();), so thecompiler 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_ptrprobe atnative_call_method.rs:1041with areceiver 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.
proggeramlug commentedon Aug 9, 2026
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-732is unchanged:92 lines below still name
object, 30 namejsval, 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:PROTECT_FROMSPACE=1 DEPTH=200), 5 runsretired_set=0/5 — protected NOTHINGHEAP_LIMIT=8 INCREMENTAL=0 CONSERVATIVE_STACK_SCAN=off) + quarantine, 5 runscopied_objectsup to 6PERRY_GC_FORCE_EVACUATE=1+ quarantine, 5 runsPERRY_GC_ZEAL=1+ quarantineDEPTH=800, 5 runsAlso zero
TypeErrors on the plain arm, so themain-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:
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=1also prints noretired_seton 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 atnative_call_method.rs:1041needs 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 throughjs_native_call_methodunder 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.
fix(gc): re-read js_native_call_method's receiver from its root per u…
fix(gc): re-read js_native_call_method's receiver from its root per u…
gc: diagnostics, two rooting fixes, and the ungated corpus/lowering c…