Repository navigation
GC: dynamic method call on a prototype-override receiver restores a stale this after a moving minor (ImplicitThisScope keeps the previous receiver unrooted) #10490
Description
Found by the package audit (compiling real npm packages from source instead of Perry's native bindings) on
Perry e6dcb62 (v0.5.1587), Linux x64. js_native_call_method has an early path for receivers whose prototype was replaced (Object.setPrototypeOf, or a per-evaluation class prototype). That path saves the caller's implicit this in a plain Rust struct (ImplicitThisScope) and writes it back after the callee returns. If an evaluating minor moves the caller's receiver during the call, the caller's next this.x reads retired from-space: TypeError: Cannot read properties of undefined, or a SIGSEGV under PERRY_GC_PROTECT_FROMSPACE=1. This is the #7154 family.
Reproduction
main.ts (no dependencies):
// Object.setPrototypeOf receiver; plain-function methods read `this` dynamically.
function churn() {
let keep = 0;
for (let i = 0; i < 2000; i++) keep += [{ i }, { s: "x" + i }].length;
return keep;
}
function run() {
const n = this.churn(); // dynamic method call; a moving GC runs inside it
return this.tag.length + n; // `this` must still be the (moved) receiver
}
const proto = { churn, run };
const N = Number(process.env.N ?? "300");
let total = 0;
for (let k = 0; k < N; k++) {
const o = { tag: "tag" + k };
Object.setPrototypeOf(o, proto);
total += o.run();
}
console.log("total", total);node main.ts
perry compile main.ts -o out && ./out # auto-optimize (default)
PERRY_NO_AUTO_OPTIMIZE=1 perry compile main.ts -o out && ./out # same failure
PERRY_GC_PROTECT_FROMSPACE=1 ./out # precise fault
PERRY_GEN_GC=0 ./out # control: non-moving collectorNo GC knobs are needed; the plain run fails deterministically.
Expected (Node 26.5.1)
total 1201690
Actual (Perry)
Plain run, both modes (at run is printed twice with no-auto and at <anonymous> with auto):
TypeError: Cannot read properties of undefined (reading 'length')
at run (<anonymous>)
at run (<anonymous>)
PERRY_GC_PROTECT_FROMSPACE=1, both modes:
[gc-fromspace-protect] FAULT: signal 11 at 0x4cc2da70b30
This address is RETIRED FROM-SPACE. The evacuating minor moved or
freed the object here and the holder kept the pre-collection address.
block=0x4cc2d9a0000 +854832 retired_bytes=1048576 retired_by_minor=#0
last-known object: user_ptr=0x4cc2da70b38 obj_type=2 size=40
The faulting instruction IS the stale use. Backtrace:
Symbolized (PERRY_KEEP_SYMBOLS=1, addr2line) as perry_fn_a_ts__run ← js_native_call_value ← js_native_call_method ← js_typed_feedback_native_call_method_by_id ← main.
PERRY_GEN_GC=0: total 1201690 (correct). N=1: total 4004 (correct, no minor runs).
Who wrote the stale value (gdb, no-auto build):
- Break on the first
copying_quarantine_from_spaces_and_flip. It is reached fromgc_safepoint_moving_minor←churn←js_native_call_value←js_native_call_method←run. - Then put a hardware watchpoint on the thread's implicit-
thiscell (HotTls.implicit_this). - Exactly one write happens before the fault:
js_implicit_this_setcalled fromjs_native_call_method+0xde3, immediately afterjs_native_call_valuereturns. It writes0x7ffd037c60a70b38, the pre-move address. runthen faults when it dereferences that value (rdi=0x37c60a70b38).
Impact
- cheerio 1.2.0 (
perry.compilePackages: ["cheerio"]). Hidenode_modules/cheerio/srcso the JS entry is compiled; with the TS sources, compilation stops on the separate enum cross-module-inline bug. Then 200 ×cheerio.load(<300-row table>)plus three queries crashes withTypeError: Cannot read properties of undefined (reading 'xmlMode')(this.optionsin_findBySelector,dist/esm/api/traversing.js:62). The crash iteration is repeatable: 19 with all queries, 56 with a selector only. Knob settings that reproduce with the package (no-auto build,bench2.tsfrom the audit):- no knobs,
N=40:FAILED at iteration 19 step a:td.name a length Cannot read properties of undefined (reading 'xmlMode'). N=200 PERRY_GC_PROTECT_FROMSPACE=1: fault atretired_by_minor=#14,obj_type=2 size=536, identical across runs.N=3 PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_PROTECT_FROMSPACE=1:FAILURE (signal 11) under seed=1 safepoints=777,retired_by_minor=#776. Seeds 2 and 3 fail at the same safepoint.N=40withPERRY_GC_PROTECT_FROMSPACE=1or=poisonruns to completion (the quarantine shifts allocation). The audit also saw a completedN=200run withPERRY_GC_PROTECT_FROMSPACE_DEPTH=64. The holder is a runtime-side copy, not an arena object:PERRY_GC_PROTECT_FROMSPACE_HOLDERS=1reports(none in 2384004 objects — the holder is outside the arena ...).
- no knobs,
- Symbolized cheerio fault:
perry_fn_node_modules_cheerio_dist_esm_api_traversing_js___findBySelector+0x1de. There, the result ofjs_implicit_this_get()is dereferenced afterthis.children()returns. Stack:_findBySelector←js_native_call_value←js_native_call_method←js_typed_feedback_native_call_method_by_id←traversing_js.find← … ←perry_closure_…_load_js__7(initialize). - gdb on the cheerio binary shows the same thing as the reduced repro:
- Minor Error during build and empty window #14 runs inside
this.children(), in a loop poll of the_matcherclosure. - The generated closure
traversing_js__29restores its own RS4GC-rooted savedthiscorrectly (the forwarded address). - Then
js_native_call_method+0xde3writes the pre-move address back into the cell, and_findBySelectorfaults on it.
- Minor Error during build and empty window #14 runs inside
- Cheerio's receiver is a
LoadedCheerioinstance. That class is declared insideload(), so everyload()evaluates a new class. That the instance carries the per-evaluation prototype flag is inferred; a plainclass X extends Basedeclared inside a factory did not reproduce the bug. - Anything that calls methods dynamically on objects with a replaced prototype (
Object.setPrototypeOf,Object.create-built instances) is affected whenever the callee allocates enough to run a copying minor. Examples are prototype-mixin libraries and CJS/UMD classes built withsetPrototypeOf.
Notes
- Suspected site (code reading, consistent with the watchpoint):
crates/perry-runtime/src/object/native_call_method.rs:1344-1395, theobject_has_individual_class_prototypeearly path added by fix(runtime): a prototype-override receiver must not lose synthesized methods #9247. It doeslet _this_scope = ImplicitThisScope::bind(receiver);and thenjs_native_call_value(...).ImplicitThisScope(native_call_method.rs:1197-1213) storesprevious: f64fromjs_implicit_this_setin a Rust struct and restores it inDrop, but never roots it. That is exactly the hazard the doc comment onjs_implicit_this_set(crates/perry-runtime/src/object/this_binding.rs:215-245) warns about. Every otherIMPLICIT_THISsave/restore in this file goes throughRuntimeHandleScope::root_nanbox_u64. - The same unrooted
ImplicitThisScope::bindappears atcrates/perry-runtime/src/object/native_call_method/primitive_methods.rs:353and:392(inferred same hazard, not reproduced). - Related closed issues: GC:
thisholds a stale from-space pointer after a moving collection inside a method body #8495 (thisstale after a moving collection inside a method body) and Sweep: ~20 unrootedjs_implicit_this_set(prev)save/restores hold a bare local across allocating user code #9445 (the sweep that rooted ~20 unrootedjs_implicit_this_set(prev)save/restores).ImplicitThisScopecame in with fix(runtime): a prototype-override receiver must not lose synthesized methods #9247 on 2026-08-31, and that sweep did not cover it. - Static checker: on this build,
--trace llvmgives pre-RS4GC IR.scripts/gc_root_dominance_check.py --unrooted-allocasovertraversing.js's module reports 0 violations, and the default and--statepointsmodes refuse, because that IR has no shadow-slot binds or statepoints. That fits a runtime-side holder, which the checker cannot see.
Activity
docs(changelog): fragment for #10490 implicit-this rooting
fix(runtime): root the displaced implicit `this` in every guard that …
docs(changelog): fragment for #10490 implicit-this rooting