Skip to content

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

@proggeramlug

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 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__runjs_native_call_valuejs_native_call_methodjs_typed_feedback_native_call_method_by_idmain.
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_minorchurnjs_native_call_valuejs_native_call_methodrun.
  • 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: _findBySelectorjs_native_call_valuejs_native_call_methodjs_typed_feedback_native_call_method_by_idtraversing_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 _matcher closure.
    • 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

Activity

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

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions