fix(runtime): thread-safety hardening for cross-thread runtime statics#1319
Merged
Conversation
`perry/thread` workers run user JS code on background OS threads, but several runtime hot paths held process-wide `static mut`s that both raced under concurrent access and stored arena-local pointers that became use-after-free when the originating thread exited. Partitions the offending state: - INTERN_TABLE, TRANSITION_CACHE_GLOBAL, SMALL_INT_CACHE → thread_local! - exception state (jump_buffers, try_depth, current_exception, has_exception, in_finally) → single per-thread ExceptionState in TLS - HANDLE_METHOD_DISPATCH / HANDLE_PROPERTY_DISPATCH / HANDLE_PROPERTY_SET_DISPATCH → AtomicPtr with Acquire/Release - Symbol.for / well-known symbol descriptions → process-lifetime Arc<str> side table; readers materialize a fresh StringHeader in the caller's arena on demand - arena::ArenaBlock::alloc bump arithmetic → checked_add No codegen changes: every removed `static mut` was grep-confirmed unreferenced from crates/perry-codegen.
Review feedback on #1319: Symbol.for / well_known_symbol inserted the pointer into the SYMBOL_REGISTRY / WELL_KNOWN_SYMBOLS map before recording its description and adding it to SYMBOL_POINTERS. A concurrent reader observing the pointer through the map would see: - registered_symbol_description() = None - description field = null - is_registered_symbol() = false …producing transiently wrong sym.description / sym.toString() / Symbol.keyFor() / is_symbol() results — exactly the half-initialized visibility pattern this PR was supposed to eliminate. Populate REGISTERED_SYMBOL_DESCRIPTIONS and SYMBOL_POINTERS first, then publish into the registry/cache. Lock order is SYMBOL_REGISTRY → SYMBOL_POINTERS → REGISTERED_SYMBOL_DESCRIPTIONS; no reader takes them in the reverse order.
proggeramlug
added a commit
that referenced
this pull request
May 22, 2026
…sweep (#1414) Rolls up 26 PRs that merged to main post-v0.5.1023 without version bumps: - node:crypto gap-fixes (#1386 #1393 #1394 #1402 #1405): randomInt, timingSafeEqual, getHashes/getCiphers, sha224/sha384, base64 digest, Buffer hash input, no-arg digest() → Buffer, pbkdf2Sync digest arg, scryptSync. - node:perf_hooks (#1321 + #1328 #1342 coverage): performance + User Timing + PerformanceObserver native impl, granular node-suite + edge-case coverage. - #1090 GC checkpoint runtime work (#1324). - #1311 geisterhand on iOS (#1316 #1383 #1384 #1385). - #1312 process.env.X (unset) is nullish undefined (#1314). - #1319 thread-safety hardening for cross-thread runtime statics. - #1322 exact-head GC evidence packet. - #1323 wasm timers dispatch through mem_call bridge (#1329). - #1317 node:timers/promises shadow-segfault fix (#1326). - #1330 node:process suite (#1331). - #1292 bcrypt.hash() returns String (#1307). - #1293 fastify .json()/.body external-fastify dispatch (#1308). - #1296 app pattern performance gaps. - #1297 diagnostics_channel parity. - #1301 iOS App Groups capability (#1313). - #1318 #1325 os/methods/modern-methods static dispatch. - #1315 expanded Node parity test coverage. - #1382 ui-ios stdlib pump for async fetch. - #1392 ui-wasm reactive state + setText (#1404).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
perry/threadworkers run user JS code on background OS threads. The runtime exposed several process-widestatic muts touched on every allocation, throw, and dispatch — concurrent worker code raced on shared mutable state, and stored arena-local pointers that became use-after-free when the originating thread exited.This PR partitions the offending state per-thread (or per-process where pointer-identity must be global), with no codegen changes — every
static mutremoved was grep-confirmed unreferenced fromcrates/perry-codegen.Before / After
INTERN_TABLE(8K entries)static mut, racy&mut INTERN_TABLE[slot]on every property-name allocation; cross-thread reads returned foreign-arena pointersthread_local!UnsafeCell; per-thread GC scanner sees only its own tableTRANSITION_CACHE_GLOBAL(16K entries)#[no_mangle]symbol but grep showed zero codegen refsthread_local!; dead#[no_mangle]export removedJUMP_BUFFERS[128],TRY_DEPTH,CURRENT_EXCEPTION,HAS_EXCEPTION,IN_FINALLY)static muts; concurrentthrows raced on the depth counter andlongjmp'd into stack frames that belonged to other threadsExceptionStatestruct in TLS;js_throwreleases the TLS borrow beforelongjmp; per-thread GC scanner rootscurrent_exceptionSMALL_INT_CACHE(Number→string)static mutof arena pointers; cache populated on thread A handed back invalid pointers on thread Bthread_local!; pinnedStringHeaders per threadHANDLE_METHOD_DISPATCH/HANDLE_PROPERTY_DISPATCH/HANDLE_PROPERTY_SET_DISPATCHstatic mut Option<fn>written once at startup, read from many threads without synchronizationAtomicPtr<()>with Acquire/Release; safe accessorshandle_method_dispatch()etc.Symbol.for/ well-known symbol descriptionsBox::leak'dSymbolHeadercarried a*mut StringHeaderallocated in the calling thread's arena; freed when that worker exited, leaving a dangling pointer in the process-lifetime symbolREGISTERED_SYMBOL_DESCRIPTIONSside table (HashMap<sym_ptr, Arc<str>>); readers materialize a freshStringHeaderin the caller's arena on demandarena::ArenaBlock::allocbump arithmetic+foraligned_offset + size; a hostilesize/alignpair could wrap and return an in-bounds pointer for an out-of-bounds regionchecked_add, matching the slow path that already did thisTest plan
cargo build --release -p perry-runtime -p perry-stdlib -p perry— cleancargo test --release -p perry-runtime --lib— 450 pass; 5 pre-existing flakes inevent_pump::tests(timing-sensitivesustained_budget_zero_spin_is_throttledpoisons the sharedSERIALmutex; commit ea6d467 already loosened this test once; grep confirmsevent_pumpdoes not touch any state changed here)./run_parity_tests.sh --filter test_gap_— 36/37 pass (97.2%); the lone failure istest_gap_class_advanced, a known failure listed intest-parity/known_failures.jsonfor static-class-block lowering, unrelated to runtime staticsNotes
Cargo.toml/CLAUDE.md/CHANGELOG.mdedits — maintainer handles version + changelog at merge.static mutwas grep-confirmed unreferenced fromcrates/perry-codegen.