Skip to content

fix(gc): root IMPLICIT_THIS so moving GC can't strand the dispatch receiver (#1813) - #1825

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-fix-1813-native-call-reentry
May 26, 2026
Merged

proggeramlug merged 1 commit into
mainfrom
worktree-fix-1813-native-call-reentry

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Fixes #1813 — the SIGSEGV race in js_native_call_method reached via @perryts/mysql's nativeScramble during concurrent Pool.acquire handshakes.

Root cause: the implicit-this thread-local cell (IMPLICIT_THIS in crates/perry-runtime/src/object/mod.rs) holds the NaN-boxed receiver for the duration of a dynamically-dispatched non-arrow method body. It is set/restored around the call by both js_native_call_method and the codegen js_implicit_this_set save/restore that wraps js_native_call_value. The receiver is a live heap object for the whole call — but the cell was plain thread-local storage that no GC root scanner visited.

When a moving GC runs during that method body — and under this workload it does: Pool.acquire runs a nested stdlib pump (js_run_stdlib_pump) that drains the handshake network IO, and the auth path allocates while building the scramble — the receiver gets evacuated (old-gen) or copied (nursery). With no root slot to rewrite, the cell kept the stale pre-move pointer, and the body's next this-derived dispatch dereferenced freed/relocated memory → the reported fault inside js_native_call_method.

This explains every symptom:

  • Concurrency-only / heisenbug: nursery copying and the C4b old-gen evacuation policy only move objects under nursery/RSS pressure, which only happens once the pool opens new connections under load. Sequential (-c 1) never moves the receiver mid-handshake.
  • Auth-plugin-agnostic (mysql_native_password SIGSEGV, caching_sha2_password wedge): both paths share concurrent connection-establishment that runs the handshake under the nested pump.
  • Sibling of SIGSEGV handling POST requests in compiled Fastify + @perryts/mysql service (0.5.1026 / 46b80d78); GET unaffected #1663 (same Fastify + @perryts/mysql POST shape): that one was a stale TLS pointer in the microtask drain; this is a stale TLS pointer in the implicit-this slot. Same class, different cell.

Fix

Register IMPLICIT_THIS as a GC mutable-root scanner (scan_implicit_this_roots_mut), wired into gc_init() alongside the other object scanners. The mark phase keeps this reachable when the cell is its only root; the rewrite phase follows the forwarding pointer so the cell tracks the receiver's relocation. Non-pointer tags (the TAG_UNDEFINED default, null/int/bool) flow through visit_nanbox_bits as no-ops, so scanning the idle cell between calls is free and safe.

Because every codegen and runtime dispatch site funnels through this one cell, a single scanner closes the gap for all of them.

Testing

  • New deterministic regression test test_implicit_this_root_scanner_marks_and_rewrites (in gc/tests/runtime_roots.rs): sets the cell to a nursery receiver, asserts the scanner marks it (not swept) and rewrites it to the relocated copy after set_forwarding_address, and that an idle TAG_UNDEFINED cell is left untouched. Mirrors the existing receiver-rewrite tests for js_native_call_method (split/replace/bigint).
  • Full gc:: module: 281 passed, 0 failed.
  • Re-ran gc::tests::runtime_roots + gc::tests::evacuation under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 (real moving GC + post-move stale-reference verifier): all green.
  • cargo fmt --all -- --check clean.

I couldn't reproduce the full MySQL-under-concurrency e2e locally (needs the server + package + load), so validation is via the unit tests that exercise the exact moving-GC + implicit-this interaction the crash depends on, plus the force-evacuate/verify stress mode.

…ceiver (#1813)

The implicit-`this` thread-local cell holds the NaN-boxed receiver for the
duration of a dynamically-dispatched non-arrow method body (set/restored by
js_native_call_method and the codegen js_implicit_this_set save/restore around
js_native_call_value). The receiver is a live heap object for the whole call,
but the cell was plain TLS that no GC root scanner visited.

When a moving GC runs during that body — e.g. @perryts/mysql's Pool.acquire
runs a nested stdlib pump that drains the handshake IO while nativeScramble
allocates — the receiver is evacuated/copied. With no root slot to rewrite, the
cell kept the stale pre-move pointer and the body's next `this`-derived dispatch
dereferenced relocated memory: the concurrent-load SIGSEGV in
js_native_call_method reported in #1813. Objects only move under nursery/RSS
pressure, which is why it only reproduced under concurrency (load-dependent
heisenbug) and across both auth plugins.

Register IMPLICIT_THIS as a GC mutable-root scanner: mark keeps `this` alive,
rewrite tracks its relocation. Non-pointer tags (the TAG_UNDEFINED default) are
no-ops, so scanning the idle cell is free. Every codegen and runtime dispatch
site funnels through this one cell, so a single scanner closes the gap.

Adds a deterministic regression test exercising mark + forwarding rewrite + the
idle-cell no-op; validated under PERRY_GC_FORCE_EVACUATE=1
PERRY_GC_VERIFY_EVACUATION=1.
@proggeramlug
proggeramlug merged commit 4a07ba0 into main May 26, 2026
10 checks passed
@proggeramlug
proggeramlug deleted the worktree-fix-1813-native-call-reentry branch May 26, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SIGSEGV race in js_native_call_method via @perryts/mysql nativeScramble during concurrent Pool.acquire handshakes (0.5.1029 / c720d2a4)

1 participant