Skip to content

Make an engine's principal realm [[HostDefined]] reachable - #2891

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:jint/engine-host-state-api
Aug 2, 2026
Merged

Make an engine's principal realm [[HostDefined]] reachable#2891
lahma merged 1 commit into
sebastienros:mainfrom
lahma:jint/engine-host-state-api

Conversation

@lahma

@lahma lahma commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Rewritten after a spec review. The first version of this PR added SetHostState/GetHostState backed by a new field on Engine, on the reasoning that Realm.HostDefined was unusable because Engine.Realm moves inside a ShadowRealm. That reasoning was factually right and normatively backwards. This version exposes the slot Jint already has.

engine.Advanced.HostDefined = scope.ServiceProvider;

The problem

Every host-facing factory in this API receives the Engine and nothing else. Engine.Advanced.AddLazyGlobal can close over per-request state because it runs on a live engine; its Options counterpart cannot, since one Options is shared by every engine built from it and a factory recorded there must not capture anything engine-affine.

A host keeping a process-wide Options therefore had no supported route from the engine back to the request it serves. Orchard Core keeps a static ConditionalWeakTable<Engine, IServiceProvider> for it and pays that table's write lock on every script evaluation of every tenant in the process.

Why the spec's slot, not a new one

A Realm Record's [[HostDefined]] is, verbatim, "Field reserved for use by hosts that need to associate additional information with a Realm Record." HTML — the largest ECMAScript host — says "A realm has a [[HostDefined]] field, which contains the realm's settings object." An IServiceProvider for the current request is that same category of thing.

The Agent Record has no [[HostDefined]] field (9.6, Table 25), so there was never an agent-level home to prefer.

Jint has had public object? Realm.HostDefined all along. What it lacked was any way to obtain a Realm from an EngineEngine.Realm became internal in #1722, which promoted Intrinsics and Global onto the engine and left HostDefined stranded. This is closing that gap rather than routing around it.

Principal realm, not current

Engine.Intrinsics and Engine.Global follow the running execution context's realm, so they change identity inside a ShadowRealm evaluation — correct, because they name the current Realm Record. HostDefined here names the principal realm, the one InitializeHostDefinedRealm created at construction, and does not move.

That is what the original PR wanted; it just got there by inventing a concept instead of naming one. And the ShadowRealm behaviour it treated as disqualifying is the specification's design:

  • ShadowRealm step 4 performs InitializeHostDefinedRealm(), whose new Realm Record defaults [[HostDefined]] to undefined;
  • the spec then supplies HostInitializeShadowRealm, "to initialize host data structures related to the ShadowRealm" — which Jint already exposes as the public virtual Host.InitializeShadowRealm(Realm).

Propagating the outer request's services into sandboxed code would be precisely the ambient authority a shadow realm exists to withhold. Default-deny plus an explicit hook is the better default, and it is what V8, SpiderMonkey and Blink all do — none of them copies embedder data into a shadow realm; Node reuses its Environment pointer explicitly while minting a fresh per-realm one.

Cross-engine precedent

Engine Per-agent slot Per-realm slot Where per-environment state goes
V8 / Node Isolate::SetData, 4 slots Context::SetEmbedderData per context (kEnvironment)
V8 / Blink 1 slot context index 2 ScriptState, 1:1 with a context
SpiderMonkey runtime private removed JS::SetRealmPrivate realm
JavaScriptCore none public fields on a JSGlobalObject subclass
ChakraCore none JsSetContextData realm
QuickJS runtime opaque context opaque both
Boa / engine262 HostDefined realm

Boa and engine262 spell it HostDefined too, which settles the naming: SetHostState/GetHostState would have introduced a second name for a thing the spec has already named, sitting next to Realm.HostDefined and inviting "what's the difference?" — whose honest answer was "nothing, one is reachable."

Ordering, deliberately

Storing in the realm rather than in a parallel Engine field is the lower-regret order. If Engine.Advanced.MainRealm / CurrentRealm are ever exposed, HostDefined is by definition MainRealm.HostDefined — one slot, no divergence, no migration. Shipping a MainRealm accessor first would have been unshippable in reverse.

Tests

Jint.Tests.PublicInterface/HostEngineStateTests.cs, 9 tests, from outside the assembly:

  • round-trip, default null, replace and detach, independence between two engines;
  • survives a RestoreGlobalSnapshot in both directions — the state persists, and a restore does not put back the value held at capture, which is what a pooling host needs to know;
  • a shared-Options lazy global resolving through it to different values in two engines, with a static factory;
  • the per-engine lazy global reaching it too;
  • the same state is visible from inside a ShadowRealm callback (typeof outerOnly proves the script really ran there);
  • a shadow realm gets its own empty slot and Host.InitializeShadowRealm can fill it — the documented escape hatch, pinned rather than asserted.

Jint.Tests 4693 and Jint.Tests.PublicInterface 1254 pass on net10.0 and net472; no new warnings.

Noted while reviewing, not fixed here

Engine._originalIntrinsics looks like an ad-hoc principal-realm stand-in and appears to be clobbered when a ShadowRealm is constructed (Intrinsics's constructor assigns it unconditionally), which would give a ClrFunction built afterwards the shadow realm's Function.prototype. Read off the source, not reproduced. Filing separately.

Follows #2890; #2892 documents the restore guarantee that the same embedder depends on.

🤖 Generated with Claude Code

Every host-facing factory in this API receives the engine and nothing else.
`Engine.Advanced.AddLazyGlobal` can close over per-request state because it runs on a
live engine, but its `Options` counterpart cannot: one `Options` instance is shared by
every engine built from it, so a factory recorded there must not capture anything
engine-affine. A host keeping a process-wide `Options` therefore had no supported way
back from the engine to the request it is serving, and real embedders keep a
`static ConditionalWeakTable<Engine, IServiceProvider>` for it, paying its write lock on
every evaluation of every tenant in the process.

The specification already has the slot. A Realm Record's [[HostDefined]] is "reserved
for use by hosts that need to associate additional information with a Realm Record",
and HTML — the largest ECMAScript host — puts a realm's settings object there. Jint has
had `Realm.HostDefined`, public and spec-named, all along. What it lacked was any way
to reach a `Realm` from an `Engine`: `Engine.Realm` became internal in sebastienros#1722, which
promoted `Intrinsics` and `Global` to the engine and left `HostDefined` stranded.

So this exposes the existing slot rather than inventing a parallel one:

    engine.Advanced.HostDefined = scope.ServiceProvider;

It names the *principal* realm — the one `InitializeHostDefinedRealm` created — not the
current one. `Engine.Intrinsics` and `Engine.Global` follow the running execution
context's realm and so change identity inside a `ShadowRealm`, which is right for them;
this must not, because a host attaching "the request this engine is serving" means one
thing for the engine's lifetime.

A shadow realm gets its own empty [[HostDefined]], and that is the design rather than a
gap: `ShadowRealm` step 4 performs `InitializeHostDefinedRealm()`, whose new record
defaults the field, and the spec supplies `HostInitializeShadowRealm` to populate it —
which Jint already exposes as `Host.InitializeShadowRealm`. Propagating the outer
request's services into sandboxed code would be exactly the ambient authority a shadow
realm exists to withhold. Both halves are tested.

Every other engine agrees on where this belongs: Node stores its `Environment` per
v8::Context rather than in an isolate slot, Blink's ScriptState is 1:1 with a context,
SpiderMonkey deleted its runtime private and kept `JS::SetRealmPrivate`, ChakraCore and
JavaScriptCore never had a runtime-level slot at all. Boa and engine262 both spell it
`HostDefined`. The Agent Record has no such field in the specification, so there was
never an agent-level home to prefer.

Storing in the realm rather than in a field on `Engine` is also the lower-regret order:
if `Engine.Advanced.MainRealm`/`CurrentRealm` are ever exposed, `HostDefined` is by
definition `MainRealm.HostDefined` — one slot, no divergence, no migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the jint/engine-host-state-api branch from e9b3315 to 9b6be20 Compare August 2, 2026 11:27
@lahma lahma changed the title Give a host a way to associate its own state with an Engine Make an engine's principal realm [[HostDefined]] reachable Aug 2, 2026
@lahma
lahma enabled auto-merge (squash) August 2, 2026 13:27
lahma added a commit that referenced this pull request Aug 2, 2026
…xists (#2893)

An engine keeps the first Intrinsics it built so that constructing a host function does
not need a realm passed in — the public ClrFunction(Engine, ...) constructor reads
_originalIntrinsics.Function.PrototypeObject for the prototype. That assignment was
unconditional, and Intrinsics is constructed once per realm.

So constructing a ShadowRealm (or $262.createRealm under test262) replaced it with the
new realm's intrinsics, and every host function built afterwards took its prototype
from a realm the surrounding script cannot reach. `log instanceof Function` came back
false for a global whose delegate happened to materialize after a script had
constructed a shadow realm — which a lazily registered global does by design, on first
read.

Assign only when the slot is still empty. The first Intrinsics an engine builds are the
ones InitializeHostDefinedRealm created, which is the engine's principal realm and the
one a host means when it builds against the engine; every later set belongs to a second
realm and must not displace it.

Found while reviewing where per-engine host state belongs (#2891): the field is an
ad-hoc stand-in for "the principal realm", and it did not hold.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit 7f0186b into sebastienros:main Aug 2, 2026
9 of 10 checks passed
legrab added a commit to legrab/pocok that referenced this pull request Aug 18, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.15.3 to
4.16.0.

<details>
<summary>Release notes</summary>

_Sourced from [Jint's
releases](https://github.com/sebastienros/jint/releases)._

## 4.16.0

Jint 4.16.0 is a **correctness- and reliability-focused release**:
alongside asynchronous module loading, proper tail calls and four new
iterator built-ins, a pre-tag review swept the whole engine and fixed
what it found — including long-standing defects that predate this cycle.
**No option defaults changed.** Behaviour changes to note up front:
`JSON.stringify` and other machine-readable output now format
invariantly under every host culture — under Swedish or Finnish locales
on .NET 8+ it used to emit a Unicode minus sign no JSON parser accepts;
`JSON.parse` now rejects trailing commas as the grammar requires; bare
identifiers at global scope resolve through the global's prototype chain
per spec; `IModuleLoader.Resolve` is consulted once per (referrer,
specifier) pair, so a loader using it as a per-import access-control
checkpoint should move the check to `LoadModule`; and an inconsistent
sort comparator now finishes with an implementation-defined order on
every target framework instead of hanging (net462/netstandard) or
throwing a CLR exception at script (net8+).

### Highlights

**Proper tail calls (#​2975).** Strict-mode calls in tail position reuse
their frame, so `"use strict"` tail recursion runs in constant stack —
the first ES2015 PTC implementation among the .NET engines.

**Asynchronous module loading (#​2872).** `IAsyncModuleLoader` and the
`AsyncModuleLoader` template let a host fetch module source over I/O
without blocking a thread; `Engine.Modules.StartImport` returns an
operation a game loop drives via `ProcessTasks()`, and `ImportAsync`
awaits without holding a thread. The spec's load phase now exists as
written, a warm-cache async loader keeps the blocking `Import` fully
synchronous, and the blocking drain wakes on a work-arrived signal
instead of polling. A module served over a transport keeps its whole url
as `Module.Location` so its own relative imports resolve, a deferred
namespace evaluates its module instead of exposing uninitialized
bindings, and an import abandoned by a global snapshot restore reports
itself faulted instead of polling forever.

**The process no longer dies for recoverable reasons.**
`Options.LimitRecursion` used to kill the host process for most useful
limits — the constraint fired, and the unwind itself overflowed the
stack; exception filters now let it unwind ~7× deeper. The new opt-in
`Options.Constraints.StackOverflowGuard` converts unbounded recursion —
reachable through eighteen distinct routes, `new`, accessors, coercions
and Proxy traps included — from a process kill into a catchable
`RangeError`, exempting strict tail calls, which grow no stack. And a
family of CLR exceptions that escaped `engine.Evaluate` past every
script `catch` are now proper JavaScript errors or correct results:
sorting with an inconsistent comparator, destructuring with a
function-valued default (`const { onChange = () => {} } = opts`),
`toLocaleString` outside `DateTime`'s range, typed-array
`defineProperty` without a value, `DataView` reads at 2³¹,
`String.replace` `$'` with a lying exec, and the first instant of year
10000.

**New built-ins.** `Iterator.prototype.join`, `chunks`, `windows` and
`includes`; `take`/`drop` now throw `RangeError` for a finite limit
above 2^53−1 per the updated proposals.
`Intl.Locale.prototype.getCollations` reports CLDR-cited collation data
that `Intl.Collator` accepts in full, a malformed `collation` option is
a `RangeError`, and `Intl.supportedValuesOf("collation")` derives from
the same lists so the three can never drift.

**Conformance, from a review that ran what the suite does not.** Two of
the fixed defects had test262 coverage only under the never-generated
`staging/` directory, and several had none at all: `parseInt` strips the
sign before testing for a hex prefix, so `parseInt("-0x10")` is −16; a
suspended `finally` no longer swallows a pending `break`/`continue`; a
Proxy (or exotic host object) as the global's prototype answers bare
identifiers through its `get` trap; `Date.prototype.toISOString` emits
the spec's six-digit expanded year and round-trips through `Date.parse`
in every spelling including year 0; iterator helpers close their
receiver exactly once and only when the spec says so, and carry their
own `@@​toStringTag`; `Map`/`Set` `size` is the prototype accessor the
spec defines rather than a phantom own property; a Proxy's
`defineProperty` trap receives the partial descriptor the caller wrote;
a string's `@@​iterator` is read once, with the primitive as receiver;
`Array.prototype.join` re-asks the array when a side effect fills a hole
mid-join; a direct eval reaches the enclosing function's `arguments` in
both modes; and `Temporal.Now` drops the methods the proposal removed.

**Embedder surface.** `OperationDeadlineConstraint` bounds a whole
multi-entry host operation; `ScriptPreparationOptions.StaticAnalysis`
trades prepare-time analysis for per-engine materialization on shared
graphs; `ModuleFactory.LocationOf` exposes the module-naming rule a host
must match; `Engine.Advanced.HostDefined` carries per-request state on a
pooled engine; the CLR exception behind an interop error is reachable
through `JintException.TryGetClrException` with opt-in
`ChainClrExceptions()`, and a host method's own `TargetException` is no
longer mistaken for a receiver mismatch; and a recursion-limit failure
propagates out of a module load instead of becoming a catchable
rejection.

**Performance, gated.** Against v4.15.3 on idle hardware, medians of
three paired runs: `controlflow-recursive` **−15.6% time and −40.4%
allocation** (proper tail calls), `bitops-3bit-bits-in-byte` −8.9%,
`math-spectral-norm` −7.3%, `crypto-sha1` −6.9%, `3d-raytrace` −5.9%,
`math-cordic` −5.8%, with a broad −1–4% tail across the call- and
string-heavy rows; no row moved outside its own measured cross-run
envelope in the other direction, and allocation is flat within ±0.2%
suite-wide. Warmed `parseInt` call sites take the frameless fast-call
lane (−13% on the parse loop), joined by the `Number` predicates,
`String.prototype.indexOf`/`startsWith`/`endsWith`/`includes`/`at`/`substr`,
global `isNaN`/`isFinite` and `Array.isArray` (−3% to −19%) and the
`Map`/`Set` method family (`map.get` hit loop −13%); existence questions
on a wrapped dictionary answer from `ContainsKey`, taking `in` −33% with
−98% allocation and `Object.keys` −37%; resolving an inherited global no
longer allocates per miss (−99.99% on the read loop) and a global
created through an inherited write keeps the in-place store; JSON
replacer/reviver eligibility is decided once per document, built-in
callback dispatch once per loop, a call site's arguments reach an
interpreted callee in registers, and function-local `let`/`const` live
in fixed slots.

**Breaking changes.**
`Int32Extensions`/`Int64Extensions`/`DoubleExtensions` — polyfill hosts
that leaked into the public API — are now internal; on
net462/netstandard2.0, code with `using Jint;` may have bound span
`Parse`/`TryParse` members through them. `JsonParser` rejects trailing
commas. `Number.parseInt.length`/`Number.parseFloat.length` report their
spec values. Post-construction mutation of an `Options` instance no
longer reaches an already-built engine, and `Options.Configure`
callbacks work again. `UnwrapIfPromise` reports a cancelled engine as
`ExecutionCanceledException` instead of a timeout. Time-zone matching is
ASCII-case-insensitive per ECMA-402.

On the [engine comparison
benchmarks](https://github.com/sebastienros/jint/blob/main/Jint.Benchmark/README.md),
Jint 4.16.0 is the fastest engine outright on 5 of 12 scripts — leading
`dromaeo-object-regexp-modern` over native V8 by 1.25× — in a
statistical tie for first on `interop-collection-traversal`, the fastest
managed engine on 10 of 12, the fastest interpreter on all 12, and
8.6×–11.2× ahead of ClearScript (native V8) on every interop row while
allocating 3.9×–12.4× less than the nearest managed competitor.

## What's Changed
* Run the repository's own host tests under Release-mode contract
verification by @​lahma in
sebastienros/jint#2866
* Update test262 suite and implement Iterator.prototype.join by @​lahma
in sebastienros/jint#2867
* Stop a closing iterator from swallowing the error that closed it by
@​lahma in sebastienros/jint#2868
* Give every benchmark row its own engine by @​lahma in
sebastienros/jint#2873
* Hand a call site's arguments to an interpreted callee in registers by
@​lahma in sebastienros/jint#2874
* Ask once per loop, not once per element, how to call a built-in's
callback by @​lahma in sebastienros/jint#2876
* Say which spec document to read for a feature by @​lahma in
sebastienros/jint#2882
* Run PR CI on every pull request, not only those targeting main by
@​lahma in sebastienros/jint#2883
* Update test262 suite and adopt the new take/drop RangeError by @​lahma
in sebastienros/jint#2877
* Implement Iterator Chunking by @​lahma in
sebastienros/jint#2878
* Say to write against the modern BCL and polyfill downwards by @​lahma
in sebastienros/jint#2884
* Implement Iterator Includes by @​lahma in
sebastienros/jint#2879
* Ask once per document, not once per key, how to call a JSON replacer
or reviver by @​lahma in sebastienros/jint#2885
* Use double.IsFinite instead of hand-rolled NaN and infinity pairs by
@​lahma in sebastienros/jint#2880
* Bump the testing group with 1 update by @​dependabot[bot] in
sebastienros/jint#2889
* Stop charging closures for per-engine lazy globals by @​lahma in
sebastienros/jint#2890
* Bump the analyzers group with 1 update by @​dependabot[bot] in
sebastienros/jint#2888
* Make the lazy-global re-arm on restore a contract instead of an
accident by @​lahma in sebastienros/jint#2892
* Let the amortized constraint cadence span top-level entries into the
engine by @​lahma in sebastienros/jint#2886
* Let a function's own let/const live in its fixed slots by @​lahma in
sebastienros/jint#2887
* Keep a host function in the engine's own realm after a second realm
exists by @​lahma in sebastienros/jint#2893
* Make an engine's principal realm [[HostDefined]] reachable by @​lahma
in sebastienros/jint#2891
* Carry a labelled break/continue target on the completion record by
@​lahma in sebastienros/jint#2894
* Make Array.prototype.sort stable on every target framework by @​lahma
in sebastienros/jint#2898
* Stop toSorted and %TypedArray%.sort hanging on an inconsistent
comparator by @​lahma in sebastienros/jint#2899
* Delete the evaluation context's dead completion channel by @​lahma in
sebastienros/jint#2895
* Stop shipping the numeric polyfill hosts as public API by @​lahma in
sebastienros/jint#2901
 ... (truncated)

Commits viewable in [compare
view](sebastienros/jint@v4.15.3...v4.16.0).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.15.3&new-version=4.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
@lahma
lahma deleted the jint/engine-host-state-api branch August 20, 2026 19:47
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