Skip to content

Per-realm built-in state for worker_threads isolation (interpreter)#981

Merged
nickna merged 5 commits into
mainfrom
wrk/per-realm-symbol-registry
Jun 27, 2026
Merged

Per-realm built-in state for worker_threads isolation (interpreter)#981
nickna merged 5 commits into
mainfrom
wrk/per-realm-symbol-registry

Conversation

@nickna

@nickna nickna commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Why

worker_threads runs each JS worker on its own .NET thread with its own Interpreter, but several guest-observable mutable built-ins were still process-global singletons shared by reference across those threads:

  • globalThis._properties
  • Math / Number.prototype / Boolean.prototype / String.prototype _extras bags
  • the Symbol.for registry

This was both a leak (one realm's Math.x = 1 visible everywhere) and, under concurrent workers, a data race on plain Dictionary instances. 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)

Phase Vector → now per-Interpreter
1 Symbol.for registry (SymbolFor/SymbolKeyFor)
2 Math (GetMath)
3 String/Number/Boolean.prototype
4 globalThis (GlobalThis)
5 cleanup: delete RealmState + dead ClearExtras; add concurrency test

Identity 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.max once Math went per-realm; fixed by gating the fast-path on !IsRealmIntrinsicName so it falls through to instance dispatch.

Tests

  • New RealmIsolationTests (interpreter-scoped): per-realm isolation for each vector + identity/extensibility, and PerRealmState_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).
  • Full suite: 14409 / 0.
  • Each phase A/B-verified against the interpreted Test262 baseline via git stash.

Scope / follow-ups (not in this PR)

  • Compiled mode emits its own globalThis/intrinsics — a separate isolation story; this PR is interpreter-only (worker-thread workers run interpreted).
  • The interpreted Test262 baseline shows a pre-existing flake (8–15 regressions, varies run-to-run) that per-realm-ization did not change — confirmed by same-session git stash A/B at every phase, all in Math/Number/Array/Object categories, none in anything these changes touch. Its root cause is not the _extras race and remains a separate investigation. The committed baseline is intentionally left untouched.
  • Remaining worker_threads gaps from the original audit (ArrayBuffer transferables, terminate()/Atomics.wait teardown, compiled-standalone needing SharpTS.dll) are unchanged.

nickna added 5 commits June 26, 2026 23:17
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.
@nickna nickna merged commit 32e8cfd into main Jun 27, 2026
2 checks passed
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.

1 participant