You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
GC: dynamic method call on a prototype-override receiver restores a stale this after a moving minor (ImplicitThisScope keeps the previous receiver unrooted) #10490
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.functionchurn(){letkeep=0;for(leti=0;i<2000;i++)keep+=[{ i },{s: "x"+i}].length;returnkeep;}functionrun(){constn=this.churn();// dynamic method call; a moving GC runs inside itreturnthis.tag.length+n;// `this` must still be the (moved) receiver}constproto={ churn, run };constN=Number(process.env.N??"300");lettotal=0;for(letk=0;k<N;k++){consto={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 collector
No 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 from gc_safepoint_moving_minor ← churn ← js_native_call_value ← js_native_call_method ← run.
Then put a hardware watchpoint on the thread's implicit-this cell (HotTls.implicit_this).
Exactly one write happens before the fault: js_implicit_this_set called from js_native_call_method+0xde3, immediately after js_native_call_value returns. It writes 0x7ffd037c60a70b38, the pre-move address.
run then faults when it dereferences that value (rdi=0x37c60a70b38).
Impact
cheerio 1.2.0 (perry.compilePackages: ["cheerio"]). Hide node_modules/cheerio/src so 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 with TypeError: Cannot read properties of undefined (reading 'xmlMode') (this.options in _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.ts from 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 at retired_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=40 with PERRY_GC_PROTECT_FROMSPACE=1 or =poison runs to completion (the quarantine shifts allocation). The audit also saw a completed N=200 run with PERRY_GC_PROTECT_FROMSPACE_DEPTH=64. The holder is a runtime-side copy, not an arena object: PERRY_GC_PROTECT_FROMSPACE_HOLDERS=1 reports (none in 2384004 objects — the holder is outside the arena ...).
Symbolized cheerio fault: perry_fn_node_modules_cheerio_dist_esm_api_traversing_js___findBySelector+0x1de. There, the result of js_implicit_this_get() is dereferenced after this.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:
The generated closure traversing_js__29 restores its own RS4GC-rooted saved this correctly (the forwarded address).
Then js_native_call_method+0xde3 writes the pre-move address back into the cell, and _findBySelector faults on it.
Cheerio's receiver is a LoadedCheerio instance. That class is declared inside load(), so every load() evaluates a new class. That the instance carries the per-evaluation prototype flag is inferred; a plain class X extends Base declared 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 with setPrototypeOf.
Notes
Suspected site (code reading, consistent with the watchpoint): crates/perry-runtime/src/object/native_call_method.rs:1344-1395, the object_has_individual_class_prototype early path added by fix(runtime): a prototype-override receiver must not lose synthesized methods #9247. It does let _this_scope = ImplicitThisScope::bind(receiver); and then js_native_call_value(...). ImplicitThisScope (native_call_method.rs:1197-1213) stores previous: f64 from js_implicit_this_set in a Rust struct and restores it in Drop, but never roots it. That is exactly the hazard the doc comment on js_implicit_this_set (crates/perry-runtime/src/object/this_binding.rs:215-245) warns about. Every other IMPLICIT_THIS save/restore in this file goes through RuntimeHandleScope::root_nanbox_u64.
The same unrooted ImplicitThisScope::bind appears at crates/perry-runtime/src/object/native_call_method/primitive_methods.rs:353 and :392 (inferred same hazard, not reproduced).
Static checker: on this build, --trace llvm gives pre-RS4GC IR. scripts/gc_root_dominance_check.py --unrooted-allocas over traversing.js's module reports 0 violations, and the default and --statepoints modes refuse, because that IR has no shadow-slot binds or statepoints. That fits a runtime-side holder, which the checker cannot see.
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_methodhas an early path for receivers whose prototype was replaced (Object.setPrototypeOf, or a per-evaluation class prototype). That path saves the caller's implicitthisin 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 nextthis.xreads retired from-space:TypeError: Cannot read properties of undefined, or a SIGSEGV underPERRY_GC_PROTECT_FROMSPACE=1. This is the #7154 family.Reproduction
main.ts(no dependencies):No GC knobs are needed; the plain run fails deterministically.
Expected (Node 26.5.1)
Actual (Perry)
Plain run, both modes (
at runis printed twice with no-auto andat <anonymous>with auto):PERRY_GC_PROTECT_FROMSPACE=1, both modes:Symbolized (
PERRY_KEEP_SYMBOLS=1,addr2line) asperry_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):
copying_quarantine_from_spaces_and_flip. It is reached fromgc_safepoint_moving_minor←churn←js_native_call_value←js_native_call_method←run.thiscell (HotTls.implicit_this).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
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):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 ...).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).this.children(), in a loop poll of the_matcherclosure.traversing_js__29restores its own RS4GC-rooted savedthiscorrectly (the forwarded address).js_native_call_method+0xde3writes the pre-move address back into the cell, and_findBySelectorfaults on it.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.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
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.ImplicitThisScope::bindappears atcrates/perry-runtime/src/object/native_call_method/primitive_methods.rs:353and:392(inferred same hazard, not reproduced).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.--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.