Per-realm built-in state for worker_threads isolation (interpreter)#981
Merged
Conversation
Move the Symbol.for / Symbol.keyFor registry off a process-global static on SharpTSSymbol and onto the Interpreter as SymbolFor()/SymbolKeyFor(), mirroring the per-realm RegExp.prototype precedent (#101). Each Interpreter is its own ECMA-262 agent, so a separate registry is the correct semantics and removes a cross-realm leak (Symbol.for returned the same symbol, and Symbol.keyFor leaked registrations, across every realm in the process) plus the cross-thread sharing that breaks concurrent worker_threads. - Interpreter.SymbolFor/SymbolKeyFor: lazy per-realm dicts, no lock (thread-confined to the realm's execution thread). - SymbolBuiltIns routes Symbol.for/keyFor through the interpreter. - Remove the now-dead static registry from SharpTSSymbol (For/KeyFor/ ClearGlobalRegistry/IsInGlobalRegistry; no remaining callers). - Drop the Symbol line from RealmState: a fresh Interpreter per realm now starts with a pristine registry by construction (Test262 builds one per test), so the reset is redundant. Math + primitive prototypes remain. - Add RealmIsolationTests: guest happy-path (both modes) + C#-level proof that two realms get distinct symbols for the same key. First phase of per-realm-izing process-global mutable built-in state for worker_threads isolation. Full suite 14402/0. Interpreted Test262 baseline unchanged by this commit (its remaining drift is pre-existing Math/ primitive-prototype flake, addressed in later phases).
Move Math off the process-global SharpTSMath singleton and onto the Interpreter (GetMath()), so guest-added properties (Math.x = 1) stay realm-local and don't race across worker threads on a shared _extras dictionary. Second phase of per-realm-izing mutable built-in state for worker_threads isolation; follows the RegExp.prototype precedent (#101). Math is both a bare global and the mutable object itself, so all three read routes are unified onto the one realm instance to preserve identity (Math === globalThis.Math within a realm) and member-identity (Math.max === Math.max, #288): - bare `Math`: resolved via TryGetRealmIntrinsic in LookupVariableRV, after the environment check so a user `let Math` still shadows. - `globalThis.Math` / `globalThis["Math"]`: ResolveGlobalThisRead routes to the realm instance unless guest code assigned an own override (which wins per ECMA-262). - `Math.max` namespace fast-path: skipped for realm intrinsics so it falls through to instance dispatch and binds methods to the realm receiver (the old fast-path bound to the static singleton — different receiver after per-realm-ization, which broke member identity). All `obj is SharpTSMath` read/write/dispatch sites operate on the instance, so they work unchanged. Drop the Math reset from RealmState (a fresh Interpreter now starts pristine by construction). RealmIsolationTests gains Math identity/extensibility (interpreter-only; compiled Math is out of scope) and a per-realm Math extras isolation test. Full suite 14404/0. Interpreted Test262 baseline drift is pre-existing flake (Math/Number/primitive-prototype _extras still race across parallel Test262 workers — addressed in Phase 3); A/B confirmed: clean tree shows the same categories with an equal-or-higher count (15 vs 8 this run).
Move String/Number/Boolean.prototype off their process-global singletons and onto the Interpreter (GetStringPrototype/GetNumberPrototype/ GetBooleanPrototype), so guest-added properties (String.prototype.x = …, and the indexed/length assignments Test262 makes before calling Array.prototype.* on a primitive) stay realm-local and don't race across worker threads on a shared _extras bag. Third phase of per-realm-izing mutable built-in state for worker_threads isolation; follows the RegExp.prototype precedent (#101) and Phases 1–2 (Symbol, Math). The namespace objects (String/Number/Boolean themselves) are immutable and stay shared singletons — only the mutable prototypes are per-realm. A single interception in EvaluateGetOnObject resolves `<Namespace>.prototype` to the realm instance (covering dotted reads and, via the synthetic-get path, string-keyed bracket reads). All `obj is SharpTS*Prototype` read/write/dispatch sites already operate on the instance, so they work unchanged. GetBoxedPrimitiveProtoExtra is threaded with the interpreter so boxed-wrapper array-like reads consult the realm prototype. With this, every former process-global mutable built-in vector is per-realm, so RealmState.ResetMutableBuiltInState becomes a no-op (each Interpreter is pristine by construction). It's retained as a no-op for the Test262 runner's call site; removing it + the caller, with a concurrency regression test, is the final cleanup (Phase 5). RealmIsolationTests gains String.prototype identity/extensibility (interpreter-only; compiled prototypes are out of scope) and a per-realm extras isolation test across all three prototypes. Full suite 14406/0. Interpreted Test262 drift is pre-existing flake (A/B same session: clean 10 vs P3 14 regressions, both within the observed 8–15 band, same categories, none in anything P3 touched — the flake is not _extras-race-driven).
Move the globalThis object off its process-global singleton onto the Interpreter (GlobalThis), so guest-assigned global properties (globalThis.x = …) stay realm-local and don't race across worker threads on a shared _properties bag. Fourth phase of per-realm-izing mutable built-in state for worker_threads isolation; follows RegExp.prototype (#101) and Phases 1–3 (Symbol, Math, primitive prototypes). Within a realm everything stays consistent: identity and self-reference (globalThis === globalThis, globalThis.globalThis === globalThis), built-in namespaces still resolve live through the shared registry (globalThis.JSON === JSON, and globalThis.Math === Math via the Phase 2 intercept) — only the user-property bag is per-realm. Resolution routes unified onto the one realm instance: - bare `globalThis` and the Node `global` alias: resolved in LookupVariableRV after the environment check (so a user `let globalThis`/`let global` still shadows), ahead of the shared global-constants table. - sloppy-mode `this`: the 8 binding sites in SharpTSFunction / SharpTSGeneratorFunction / SharpTSAsyncGeneratorFunction now bind interpreter.GlobalThis instead of the static singleton. - CommonJS `global`: bound to the realm instance in the module environment. The namespace/property dispatch sites (`obj is SharpTSGlobalThis`, ResolveGlobalThisRead, the IndexTarget.GlobalThis paths) already operate on the instance, so they work unchanged. The static Instance is retained as the BuiltInRegistry template/fallback; no guest read reaches it. Full suite 14408/0. Interpreted Test262 drift is pre-existing flake (A/B same session: clean 10 vs P4 15 regressions, both within the observed 8–15 band, same categories, none globalThis-related).
…r isolation P5) Final cleanup of the per-realm-ization (Phases 1–4). Every former process-global mutable built-in vector — RegExp.prototype (#101), the Symbol.for registry, Math, the String/Number/Boolean prototypes, and globalThis — is now owned per-realm by the Interpreter, so a fresh `new Interpreter(...)` is pristine by construction and there is nothing left to reset. - Delete RealmState (ResetMutableBuiltInState had become a no-op) and its per-test caller in Test262Runner; each Test262 file already gets a fresh Interpreter, which is now sufficient on its own. - Remove the now-dead ClearExtras() methods from SharpTSMath and the three primitive prototypes (their only caller was RealmState). - Add PerRealmState_IsIsolated_UnderConcurrentThreads: 8 interpreters on 8 threads mutate Symbol.for / Math / globalThis / the primitive prototypes concurrently (500 iterations each), asserting each realm reads back only its own values. This is the worker-isolation payoff and the regression guard against reverting any vector to process-global state — under the old shared singletons it would both clobber across realms and race the backing dictionaries. Full suite 14409/0. Interpreted Test262 unchanged (the removed reset was already a no-op); its remaining drift is pre-existing flake.
This was referenced Jun 27, 2026
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.
Why
worker_threadsruns each JS worker on its own .NET thread with its ownInterpreter, but several guest-observable mutable built-ins were still process-global singletons shared by reference across those threads:globalThis._propertiesMath/Number.prototype/Boolean.prototype/String.prototype_extrasbagsSymbol.forregistryThis was both a leak (one realm's
Math.x = 1visible everywhere) and, under concurrent workers, a data race on plainDictionaryinstances. The prior mitigation (RealmState.ResetMutableBuiltInState) was a sequential reset built for Test262's serial realm reuse — it cannot help concurrent realms and is unsafe to call while another realm is live.This PR extends the per-realm pattern already proven for
RegExp.prototype(#101) to every remaining vector, so concurrent worker threads are isolated by construction.What changed (5 phases, one commit each, suite green at every step)
InterpreterSymbol.forregistry (SymbolFor/SymbolKeyFor)Math(GetMath)String/Number/Boolean.prototypeglobalThis(GlobalThis)RealmState+ deadClearExtras; add concurrency testIdentity and built-in delegation are preserved within a realm:
Math === globalThis.Math,Math.max === Math.max(#288),globalThis === globalThis,globalThis.globalThis === globalThis,globalThis.JSON === JSON. Built-in namespaces (String/Number/Boolean, JSON, …) stay shared singletons — only the mutable user-property/extras bags are per-realm.Notable detail: the namespace fast-path bound built-in methods to the static singleton, which broke
Math.max === Math.maxonceMathwent per-realm; fixed by gating the fast-path on!IsRealmIntrinsicNameso it falls through to instance dispatch.Tests
RealmIsolationTests(interpreter-scoped): per-realm isolation for each vector + identity/extensibility, andPerRealmState_IsIsolated_UnderConcurrentThreads— 8 interpreters on 8 OS threads × 500 iterations mutating all vectors concurrently, asserting each realm reads back only its own values (the worker-isolation payoff + regression guard).git stash.Scope / follow-ups (not in this PR)
globalThis/intrinsics — a separate isolation story; this PR is interpreter-only (worker-thread workers run interpreted).git stashA/B at every phase, all in Math/Number/Array/Object categories, none in anything these changes touch. Its root cause is not the_extrasrace and remains a separate investigation. The committed baseline is intentionally left untouched.worker_threadsgaps from the original audit (ArrayBuffer transferables,terminate()/Atomics.waitteardown, compiled-standalone needingSharpTS.dll) are unchanged.