Skip to content

fix(runtime): root every saved implicit-this across the user code it brackets — 121 sites, not ~20 (#9445) - #9541

Closed
proggeramlug wants to merge 4 commits into
PerryTS:mainfrom
proggeramlug:fix/9445-implicit-this-restore-sweep
Closed

fix(runtime): root every saved implicit-this across the user code it brackets — 121 sites, not ~20 (#9445)#9541
proggeramlug wants to merge 4 commits into
PerryTS:mainfrom
proggeramlug:fix/9445-implicit-this-restore-sweep

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #9445.

What

Every let prev = js_implicit_this_set(receiver); … user code …; js_implicit_this_set(prev) in the runtime kept the caller's receiver in a bare Rust local across a call that allocates. An evacuating young-gen minor inside the window moves that object; the restore then installed a retired from-space address as the caller's this, which reads as undefined on the caller's next member access (the #9417 shape PR #9444 fixed for the four accessor sites).

All remaining sites now root the saved value in a RuntimeHandleScope and re-read it at the restore — the idiom prototype_chain.rs and #9444 already use:

let this_scope = crate::gc::RuntimeHandleScope::new(); // #9445
let prev = this_scope.root_nanbox_f64(crate::object::js_implicit_this_set(receiver));
… user code …
crate::object::js_implicit_this_set(prev.get_nanbox_f64());

Inventory: 122 sites, not ~20

The issue lists 24 sites in 12 files. A classifier over every let X = js_implicit_this_set(…) in crates/perry-runtime/src (test modules excluded; script in the description below) finds 130 saves: 9 already rooted, one routed through the interpreter's own root stack (dyn_eval/bridge.rs), one a #[test] with a numeric sentinel, and 119 restoring a bare local — plus two the regex/filter missed (array/iterator.rs:484, an Option<f64> save; node_submodules/test.rs:808, in a file named like a test module). 121 converted, in 65 files — plus one more that landed with #9518 (os_process_streams.rs's EOF-flush loop) while this sweep was in flight, caught by re-running the classifier after the rebase: 122. The issue's 24 are a subset. This PR converts all 122 rather than the enumerated subset, because the hazard is identical at each and a partial sweep leaves the same silent-wrong-answer bug on paths a later report would misattribute in exactly the way #9417 was.

Which sites could have been left alone: none. The issue asks for the per-site check. Every one of the 122 calls user-reachable code in the window — a closure (js_closure_call*, js_native_call_value), a class getter/setter or static method through a vtable or registry, a Proxy trap, a then, a [Symbol.iterator]/next, a toJSON/replacer/reviver, a stream hook — or a [[Get]] that can reach a getter (proxy/reflect.rs:72). There is no site whose intervening call provably cannot allocate.

Per-site mechanics:

  • Callback loops root the displaced receiver once per loop, not once per callback (Map/Set/URLSearchParams forEach, EventTarget dispatch, cluster/child-process/pty emitters, fs.watch fan-out, the timer batch, to_string.rs's toString/valueOf probe, TypedArray.from's map callback): one handle holds js_implicit_this_get() before the loop and every iteration restores from it. Sites that already own a RuntimeHandleScope (timers, JSON, ToPrimitive, super[key](), promisify, watchers, …) push onto it instead of opening another.
  • Three sites consumed the receiver again after the call and now re-read it through a root too: intl_subclass_super and temporal_subclass_super (this_box after the parent constructor), and the three process.stdin listener loops (this was read once before the loop and reused across listeners; it is now re-read from the rooted singleton per listener).
  • array/iterator.rs:484 (an Option<f64> save) and node_submodules/test.rs:808 (the node:test mock-call path, missed by the test-file filter) were converted by hand; the other 119 by the script.
  • js_implicit_this_set's doc comment now states the contract so the next site cannot get it wrong.

Verification

test-files/test_gap_9445_implicit_this_restore_sweep.ts — 34 cases, one per synchronously reachable site family. Each is a factory returning a fresh young object whose run is a function-expression method (reads this off the implicit-this cell, not a captured slot); run drives one runtime site with a callback that allocates past the nursery, then reads this.inner.def. No GC env knobs. Node prints bad=0 for every line.

On unfixed main (0b24670), the cases below print a non-zero count; each one is a runtime site the fixture reaches with nothing between it and run's next this read:

case runtime site unfixed main
map_forEach map.rs forEach bad=6Cannot read properties of undefined (reading 'def')
set_forEach set.rs forEach bad=6Cannot read properties of undefined (reading 'def')
urlsearchparams_forEach url/search_params.rs forEach bad=6Cannot read properties of undefined (reading 'def')
event_target_dispatch event_target.rs dispatch bad=7Cannot read properties of undefined (reading 'def')
to_property_key_toString object/property_key.rs:132 (ToPropertyKey) bad=6Cannot read properties of undefined (reading 'def')
defineProperty_setter object/field_set_by_name/tail.rs accessor setter bad=6Cannot read properties of undefined (reading 'def')
function_object_getter closure/dynamic_props.rs accessor getter bad=6Cannot read properties of undefined (reading 'def')
valueOf_to_primitive value/to_string.rs OrdinaryToPrimitive bad=6Cannot read properties of undefined (reading 'def')
toString_template value/to_string.rs OrdinaryToPrimitive bad=6Cannot read properties of undefined (reading 'def')
symbol_toPrimitive symbol/iterator.rs:653 @@toPrimitive bad=6Cannot read properties of undefined (reading 'def')
json_stringify_getter object/descriptor_state.rs:963 JSON getter bad=7Cannot read properties of undefined (reading 'def')
json_stringify_toJSON json/stringify.rs toJSON bad=6Cannot read properties of undefined (reading 'def')
json_stringify_replacer json/replacer.rs replacer SIGSEGV (stale restore hit a recycled cell)
json_parse_reviver json/reviver.rs reviver bad=14Cannot read properties of undefined (reading 'def')
for_of_user_iterator iterator protocol (for…of) bad=0 (masked by an outer rooted save/restore)
spread_user_iterator array/iterator.rs spread bad=7Cannot read properties of undefined (reading 'def')
destructure_user_iterator array/iterator.rs:1304 destructuring next() bad=6Cannot read properties of undefined (reading 'def')
array_from_user_iterator array/iterator.rs Array.from bad=7Cannot read properties of undefined (reading 'def')
proxy_get_trap proxy.rs:698 get trap bad=7Cannot read properties of undefined (reading 'def')
proxy_set_trap proxy.rs set trap bad=7Cannot read properties of undefined (reading 'def')
proxy_apply_trap proxy/apply_construct.rs apply trap bad=7Cannot read properties of undefined (reading 'def')
proxy_construct_trap proxy/apply_construct.rs construct trap bad=7Cannot read properties of undefined (reading 'def')
reflect_apply proxy.rs:895 Reflect.apply bad=6Cannot read properties of undefined (reading 'def')
reflect_get_receiver_getter proxy/reflect.rs Reflect.get getter bad=6Cannot read properties of undefined (reading 'def')
bound_function_call closure/dispatch/bound.rs bound call bad=0 (masked by an outer rooted save/restore)
string_replace_callback regex/replace_fn.rs replace callback bad=7Cannot read properties of undefined (reading 'def')
using_dispose disposable.rs @@dispose bad=0 (masked by an outer rooted save/restore)
writable_write node_stream.rs _write bad=6Cannot read properties of undefined (reading 'def')
writable_writev node_stream.rs _writev bad=6Cannot read properties of undefined (reading 'def')
transform_transform node_stream.rs _transform bad=3Cannot read properties of undefined (reading 'def')
writable_construct node_stream_readwrite.rs _construct bad=3Cannot read properties of undefined (reading 'def')
readable_read node_stream_readwrite.rs _read bad=2Cannot read properties of undefined (reading 'def')
writable_final node_stream_readwrite.rs _final bad=4Cannot read properties of undefined (reading 'def')
transform_flush node_stream_readwrite.rs _flush bad=3Cannot read properties of undefined (reading 'def')

The cases that print 0 on main are reached through a dispatch that carries its own (already rooted) save/restore — js_native_call_method re-installs the correct receiver after the inner stale restore, masking it from run. They pin parity and the shape but are not witnesses for their site; those sites carry the mechanical fix only.

Event-loop-driven sites (timers, process.stdin, dgram, cluster, fs.watch, pty, child process, messaging) only ever see a heap prev from a nested pump (#1813's shape) and have no deterministic synchronous reproduction; they carry the same mechanical fix.

Fixed build: every line bad=0, byte-identical to node --experimental-strip-types, exit 0, 2.9 s. Two candidate cases were dropped because they diverge for reasons outside this PR: a typed-array defineProperty accessor and a Date toISOString override (parity gaps, #9529), and a util.callbackify thenable whose exit-time microtask drain SIGSEGVs on every build including main (#9539, promise-side rooting; it was never a witness here — bad=0 on main too).

Also fixed, same family, found by the fixture

JSON.stringify(value, replacerFn) still SIGSEGV'd with the prev rooting alone. js_json_stringify_full (and the compact entry js_json_stringify_with_replacer) run the root toJSON and the root replacer call — both user code — and then hand the raw replacer closure pointer and the "" key to the walk. PERRY_GC_PROTECT_FROMSPACE=1 + a PERRY_KEEP_SYMBOLS=1 build faulted in js_closure_call2call_replacerstringify_object_with_replacer_pretty: the walk (which roots everything it derives, correctly) was handed a retired closure. Both are now rooted across the root-level calls and re-read at each use (json/replacer.rs, separate commit).

Cost

Measured on perrymaster (16-core Linux, other sessions' builds running — treat as ±3%), six alternating rounds each, sorted:

bench (self-timed) main ms this PR ms
user-iterator for…of, 2M steps 322 323 324 325 326 328 329 330 334 338 339 339
Map.forEach, 2M callbacks 57.3 57.3 57.5 58.0 58.1 58.3 59.6 59.7 60.1 60.3 60.6 64.4
JSON.stringify with toJSON, 500k 107 108 108 109 109 111 107 107 109 109 110 122
valueOf ToPrimitive, 2M ~2000 ~2000

That is ≈ +4 ns per user-iterator step and ≈ +1 ns per forEach callback (after the per-loop hoist; the first cut without it was +2.8 ns/callback). The remaining cost at single-call hot sites (js_iterator_next_result, toJSON) is one RuntimeHandleScope new/push/get/drop. A dedicated rooted implicit-this save stack would be cheaper still, but it is new GC machinery (root scanner + trap savepoints) and the issue asked for the existing idiom; flagging it rather than building it.

Classifier

# UNROOTED: `let V = …js_implicit_this_set(…)` whose restore is `js_implicit_this_set(V)`
# ROOTED:   V is passed to root_nanbox_*/root_heap_word within 5 lines

(full script in the session.)

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 6 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 8 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: d01f5525-9ed3-48d0-a9d5-3339619f1beb

📥 Commits

Reviewing files that changed from the base of the PR and between b13a200 and 125df98.

📒 Files selected for processing (68)
  • changelog.d/9445-implicit-this-restore-sweep.md
  • crates/perry-runtime/src/array/iterator.rs
  • crates/perry-runtime/src/async_hooks.rs
  • crates/perry-runtime/src/child_process/emitter.rs
  • crates/perry-runtime/src/closure/dispatch/bound.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/cluster.rs
  • crates/perry-runtime/src/collection_iter.rs
  • crates/perry-runtime/src/dgram.rs
  • crates/perry-runtime/src/disposable.rs
  • crates/perry-runtime/src/event_target.rs
  • crates/perry-runtime/src/fs/dir_glob_watch/watch.rs
  • crates/perry-runtime/src/fs/stream/write_file_input.rs
  • crates/perry-runtime/src/intl/subclass.rs
  • crates/perry-runtime/src/json/replacer.rs
  • crates/perry-runtime/src/json/reviver.rs
  • crates/perry-runtime/src/json/stringify.rs
  • crates/perry-runtime/src/json/stringify_scalars.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/messaging.rs
  • crates/perry-runtime/src/node_api_host/functions.rs
  • crates/perry-runtime/src/node_inspector.rs
  • crates/perry-runtime/src/node_repl.rs
  • crates/perry-runtime/src/node_stream.rs
  • crates/perry-runtime/src/node_stream_destroy_state.rs
  • crates/perry-runtime/src/node_stream_event_emitter.rs
  • crates/perry-runtime/src/node_stream_readwrite.rs
  • crates/perry-runtime/src/node_submodules/consumers.rs
  • crates/perry-runtime/src/node_submodules/diagnostics.rs
  • crates/perry-runtime/src/node_submodules/stream_promises.rs
  • crates/perry-runtime/src/node_submodules/test.rs
  • crates/perry-runtime/src/node_submodules/trace_events.rs
  • crates/perry-runtime/src/node_vm/modules.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/object/class_registry/parent_static.rs
  • crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs
  • crates/perry-runtime/src/object/class_registry/prototype_objects.rs
  • crates/perry-runtime/src/object/date_proto_thunks.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/object/field_set_by_name/tail.rs
  • crates/perry-runtime/src/object/global_this/bigint_promise.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/object/native_call_method/handle_methods.rs
  • crates/perry-runtime/src/object/native_call_method/object_proto.rs
  • crates/perry-runtime/src/object/property_key.rs
  • crates/perry-runtime/src/object/this_binding.rs
  • crates/perry-runtime/src/os_process_streams.rs
  • crates/perry-runtime/src/promise/assimilate.rs
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-runtime/src/promise/checked_dispatch.rs
  • crates/perry-runtime/src/promise/spec_combinators.rs
  • crates/perry-runtime/src/promise/then.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/proxy/apply_construct.rs
  • crates/perry-runtime/src/proxy/reflect.rs
  • crates/perry-runtime/src/pty/mod.rs
  • crates/perry-runtime/src/regex/replace_fn.rs
  • crates/perry-runtime/src/set.rs
  • crates/perry-runtime/src/symbol/accessors.rs
  • crates/perry-runtime/src/symbol/iterator.rs
  • crates/perry-runtime/src/timer.rs
  • crates/perry-runtime/src/typedarray_props.rs
  • crates/perry-runtime/src/url/search_params.rs
  • crates/perry-runtime/src/util_promisify.rs
  • crates/perry-runtime/src/value/to_string.rs
  • crates/perry-runtime/src/value/to_string_class_ref.rs
  • test-files/test_gap_9445_implicit_this_restore_sweep.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 4 commits September 2, 2026 15:32
…t brackets (PerryTS#9445)

Every `let prev = js_implicit_this_set(receiver); …user code…;
js_implicit_this_set(prev)` in the runtime held the caller's receiver in a
bare Rust local across a call that allocates. An evacuating young-gen minor
inside the window moves that object; the restore then installed a retired
from-space address as the caller's `this`, which reads as `undefined` on the
next member access (the PerryTS#9417 shape). Root the saved value in a
RuntimeHandleScope and re-read it at the restore — the idiom PR PerryTS#9444 used
for the accessor sites — at all 121 remaining sites, plus the receivers that
three of those sites consume again after the call.

Claude-Session: https://claude.ai/code/session_01L11XMMWrR9Wz11dHpq4gXS
…el toJSON/replacer calls (PerryTS#9445)

Found by the PerryTS#9445 fixture: with the saved implicit-`this` rooted, an
allocating replacer still SIGSEGV'd in js_closure_call2. js_json_stringify_full
and js_json_stringify_with_replacer run the root toJSON and the root replacer
call — both user code — and then handed the walk the raw closure pointer and
the "" key. Root both and re-read at each use.
…se existing handle scopes (PerryTS#9445)

Callback loops (Map/Set/URLSearchParams forEach, EventTarget dispatch, the
emitters, fs.watch fan-out, the timer batch, TypedArray.from's map callback)
root the caller's receiver once before the loop and restore from that handle
each iteration, instead of opening a scope per callback. Single-call sites
that already own a RuntimeHandleScope push onto it. Three bare-name callers
of js_implicit_this_get are fully qualified.
…re across a moving minor, plus changelog
@proggeramlug
proggeramlug force-pushed the fix/9445-implicit-this-restore-sweep branch from a5796f3 to 125df98 Compare September 2, 2026 13:33
@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 2, 2026
proggeramlug pushed a commit that referenced this pull request Sep 2, 2026
…s replacer closure reads via with_const_ptr
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #9546 (rebase-merge, authorship preserved). Train resolution: the four os_process_streams.rs sites merge your #9445 rooting with the with_const_ptr conversions from train70; the json/replacer.rs rooted-replacer reads went through with_const_ptr to satisfy the debt ratchet.

proggeramlug pushed a commit that referenced this pull request Sep 2, 2026
…9511/#9541/#9543 (fixture verified byte-identical on main before trimming)
proggeramlug pushed a commit that referenced this pull request Sep 2, 2026
…9511/#9541/#9543 (fixture verified byte-identical on main before trimming)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sweep: ~20 unrooted js_implicit_this_set(prev) save/restores hold a bare local across allocating user code

1 participant