Add engine-reuse ergonomics: post-construction lazy globals and a snapshot scope - #2862
Merged
Merged
Conversation
`Options.AddLazyGlobal` defers building a global's value until script reads the name, but it is options-time only. A host whose globals are computed from per-request data only knows them after `new Engine(...)`, and the workaround reachable from the public surface — `FastSetProperty` with a `CustomJsValue` descriptor of its own — is declined by the global-identifier inline cache, which never caches a `CustomJsValue` descriptor. Lazy per-request globals were therefore a choice between eager construction and an uncacheable read. `engine.Advanced.AddLazyGlobal(name, factory, flags)` is the options-time body invoked on a live engine: the same `LazyPropertyDescriptor`, which clears its `CustomJsValue` flag the moment it materializes and so rejoins the caches. Because it belongs to one engine rather than to a shared `Options`, its factory may capture engine-affine state, which is the case the options-time API cannot express. The one thing a post-construction install must get right that a construction-time one need not is invalidation: the live engine's handler trees may already hold a resolved binding for that name, and a warmed identifier site keeps the previous descriptor by reference, revalidating it against `GlobalObject._propertiesVersion` alone. `SetProperty` bumps that on every path it can take — shared-layout slot replacement, hybrid side-dictionary add, plain dictionary store, deopt fallback — which is why the install goes through it rather than writing `_properties` directly. It correctly leaves `_lexicalMutations` and `_envBindingInjectionEpoch` alone: a global property is not a lexical binding and injects nothing into an existing environment. Tests pin both halves. In `Jint.Tests.PublicInterface` (no internals access, so it proves third-party reachability) a site that already failed to resolve the name, a warmed eager global and a warmed built-in each observe the new binding; the factory runs lazily and at most once; existence and enumeration see the name without materializing it; flags are honoured. In `Jint.Tests` the version bump is asserted on all four storage paths and the two lexical counters are asserted unchanged. A deliberately naive implementation that stored straight into `_properties` fails three of these. The snapshot interplay is pinned as it falls out rather than papered over: a global installed after a capture is gone after the restore, and one whose factory had not run at capture time is returned to that unmaterialized state, so it may run again. That is what lets a pooled engine keep the laziness across evaluations, and the XML doc says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
`CaptureGlobalSnapshot` / `RestoreGlobalSnapshot` document the shape every reusing host ends up writing: lock, evaluate, restore in a `finally`. The `finally` is the part that is easy to leave off, and leaving it off is invisible until a script throws — the globals that evaluation declared are then handed to the next caller, which is the exact failure the API exists to prevent. `engine.Advanced.WithRestoredGlobals(snapshot, action)` is that `try`/`finally` and nothing else. Both arguments are null-checked; everything after that is left to `RestoreGlobalSnapshot`'s own guards rather than duplicated here, so a foreign snapshot or an outstanding asynchronous evaluation fails exactly as documented — after the action has already run, since the wrapper adds a finally, it does not pre-validate on the restore's behalf. A test pins that ordering so it is a stated property rather than an accident. The doc is careful not to oversell it: identical non-guarantees to `RestoreGlobalSnapshot`, linked rather than restated, because this is still a configuration-reuse primitive and not an isolation boundary — it is a `finally`, not a sandbox. Exceptions from the action propagate, after the restore has run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
lahma
enabled auto-merge (squash)
July 29, 2026 09:30
This was referenced Jul 29, 2026
This was referenced Jul 29, 2026
This was referenced Aug 7, 2026
legrab
added a commit
to legrab/pocok
that referenced
this pull request
Aug 14, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.14.0 to 4.15.3. <details> <summary>Release notes</summary> _Sourced from [Jint's releases](https://github.com/sebastienros/jint/releases)._ ## 4.15.3 Jint 4.15.3 rounds out the 4.15 embedder line: every item here answers friction a real integration reported while adopting the host-integration surface 4.15.0 introduced. Everything is additive — no option defaults changed and no behavior changes for existing code. - **`Engine.Advanced.AddLazyGlobal`** (#2862) — install a lazy global on a live engine, so a host whose globals are computed from per-request data can defer building them until script reads the name; the same PR adds `Engine.Advanced.WithRestoredGlobals(snapshot, action)`, the `try`/`finally` every snapshot-reusing host was writing by hand. - **`PropertyDescriptor.CreateLazy`** (#2865) — a public lazy property descriptor that materializes once and then rejoins the read and write inline caches, which a hand-rolled `CustomJsValue` descriptor never could; it is the sanctioned way to build for any host object property what `AddLazyGlobal` does for a global. - **`Options.AddImmutableCrossing(params Type[])`** (#2863) — a host promise that instances of the declared CLR types do not change while they are exposed to the engine, in exchange for which a wrapped object memoizes its resolved reads. On the nested-document walk it was built for that measures −43% to −84% time and −99% allocation against the undeclared path, with dictionary and `JsonNode` sources converging to identical steady-state cost. It is a promise: a declared object mutated anyway will serve stale reads. - **Host-contract verification from the shipped package** (#2864) — set the `Jint.EnableHostContractVerification` AppContext switch before the first use of any Jint type and the checks that catch a host answering one extension point in a way that contradicts another run in Release, throwing with a descriptive message. Embedders can now run their suites against the exact package they deploy instead of building a Debug Jint from source, and CI now runs this repository's own host suites that way too (#2866). - **`Engine.Advanced.HasSharedShape`** (#2861) — a stable, pinnable predicate for whether `JsObject.Create`, `CreateFromEntries` or `JsObjectShape.Instantiate` actually produced a shared-layout object, which the explicitly non-contractual `ObjectRepresentation` diagnostic could never be. - **`JsString.Create(string)` is now public** (#2860) — the counterpart of `JsNumber.Create`, answering the empty string and single-character ASCII from interned instances instead of allocating. - **Documentation** (#2859) — an unresolvable reference's `Base` holds an internal sentinel rather than `undefined`, and resolver authors returning it were leaking that sentinel string into scripts; the docs and the in-repo sample now show the right idiom. ## What's Changed * Make JsString.Create(string) public by @lahma in sebastienros/jint#2860 * Document that an unresolvable reference's base is a sentinel, not undefined by @lahma in sebastienros/jint#2859 * Add engine-reuse ergonomics: post-construction lazy globals and a snapshot scope by @lahma in sebastienros/jint#2862 * Give hosts a stable predicate for "did the shaping actually happen" by @lahma in sebastienros/jint#2861 * Make the host-contract verifiers reachable, and complete by @lahma in sebastienros/jint#2864 * Give hosts a lazy descriptor that rejoins the caches once it holds a value by @lahma in sebastienros/jint#2865 * Let a host promise a wrapped object is immutable and have its reads memoized by @lahma in sebastienros/jint#2863 **Full Changelog**: sebastienros/jint@v4.15.2...v4.15.3 ## 4.15.2 Jint 4.15.2 is a fix release. - **Async and generator suspension** — loop iteration state is preserved across suspensions in async generators and `for await...of` (#2852), an `await` suspending a right-hand side no longer stores the suspension sentinel into the target (#2855), and suspension-node resolution unwraps correctly (#2856). - **Correctness** — calling and `instanceof` work on bound functions whose target is itself bound (#2853), and inherited accessors reached through `ObjectInstance.TryGetValue` receive the original receiver (#2854). - **Performance** — the builtin-shape probe lane answers an authoritative miss without falling back to the slow path, which named-index misses on shaped objects were paying on every probe (#2858); and the `JsObject.Create` values span is now nullable-annotated so a lazy slot's required `null` needs no suppression (#2851). ## What's Changed * Annotate the layout values span so a lazy entry's null needs no suppression by @lahma in sebastienros/jint#2851 * Unwrap a JintStatement suspension node so for-await resumes into the right branch by @HermanusMuellerEU in sebastienros/jint#2856 * Fix calling and instanceof on bound functions whose target is itself bound by @HermanusMuellerEU in sebastienros/jint#2853 * Preserve loop iteration state across suspensions in async generators and for-await-of by @HermanusMuellerEU in sebastienros/jint#2852 * Pass the original receiver to inherited accessors in ObjectInstance.TryGetValue by @HermanusMuellerEU in sebastienros/jint#2854 * Do not store the suspension sentinel when an await suspends a right-hand side by @HermanusMuellerEU in sebastienros/jint#2855 * Answer an authoritative miss from the builtin-shape probe lane by @lahma in sebastienros/jint#2858 ## New Contributors * @HermanusMuellerEU made their first contribution in sebastienros/jint#2856 **Full Changelog**: sebastienros/jint@v4.15.1...v4.15.2 ## 4.15.1 Jint 4.15.1 is a small refinement release shaped by the first real-world adoptions of 4.15.0's host-integration surface — every change answers a need a shipping embedder hit within days of the release. No behavior changes for existing code, with one deliberate spec-path improvement: `Object.freeze` no longer forces lazily-declared properties into existence just to validate attribute-only redefinitions (so freezing `globalThis` no longer materializes every lazy global). - **`JsObjectLayout` lazy slots** (#2850) — a fresh shaped object per item can now defer expensive members: declare `AddLazy(name, factory)` on the layout, pass per-instance state to `JsObject.Create`, and the member materializes on first read while every item keeps sharing one hidden class. In the motivating host shape (a 15-member event envelope with 4 expensive members), builds measure ~3.6× faster with 4× fewer allocations than the eager layout, and ~1.6× faster than the dictionary-mode workaround it replaces. - **Observability for host tests** — `Engine.Advanced.GetPropertyAccessSemantics` (#2847) lets a test pin the access semantics the engine derived for a host type, and `GetInteropConversionDiagnostics` (#2848) counts CLR array crossings so a host can audit its `ArrayConversion` exposure — including through dependencies it doesn't own. Both carry the same non-contractual, diagnostics-only framing as `GetObjectRepresentation`. - **`PropertyFlag.NonWritable` / `OnlyConfigurable`** (#2849) complete the named combination lattice for the descriptor shapes hosts actually build. - **Documentation** (#2846) — the contracts a real adoption tripped over, stated where an embedder will find them: `JsonSerializer` reuse and its `Undefined` sentinel, the `BigInt.prototype.toJSON` escape hatch, what does *not* route through `GetOwnProperties()`, and the snapshot reuse recipe. ## What's Changed * Document the contracts a real adoption tripped over by @lahma in sebastienros/jint#2846 * Let a test observe the access semantics the engine derived for a host type by @lahma in sebastienros/jint#2847 * Name the two PropertyFlag combinations hosts actually build by @lahma in sebastienros/jint#2849 * Count CLR array conversions so a host can audit its crossing semantics by @lahma in sebastienros/jint#2848 * Let a layout declare lazy slots so a shaped object can defer expensive members by @lahma in sebastienros/jint#2850 **Full Changelog**: sebastienros/jint@v4.15.0...v4.15.1 ## 4.15.0 Jint 4.15.0 is an **embedder-focused release**: the host-integration surface was widened after auditing six real-world integrations, engine reuse got first-class support, and an adversarial pre-release review verified every change since 4.14.0 test-first. **No option defaults changed.** One behavior change to note: re-importing a module whose evaluation failed now rethrows the recorded error instead of returning a namespace (#2827). ### Highlights **Host objects** - Answer reads value-direct with `TryGetOwnPropertyValue` (#2808) and existence/enumerability questions without materializing descriptors with `ProbeOwnProperty` (#2803); access semantics are derived from the type automatically (#2804). Warm host reads cost zero probes, and Debug builds verify every answer. - `ArrayLikeObject` (#2835, #2841) projects a live indexed collection by implementing two members — indexed reads, `for-of`, spread, generics and `JSON.stringify` cost one virtual call per element. - `JsObjectShape` (#2830, #2836, #2840) declares shared prototypes once per process with lazily materialized per-realm members — and a shaped prototype can serve the prototype-method inline cache, which no host subclass can. - First adopter: a DOM binding cut indexed-read allocations by 60% and existence probes to zero. **Engine reuse** - `CaptureGlobalSnapshot` / `RestoreGlobalSnapshot` (#2834) restore a configured global between evaluations: top-level `let`/`const` cleared (nothing else can), stale promise continuations fenced, warm per-engine caches kept. Configuration reuse — deliberately not an isolation boundary. - Fresh-engine hosts register globals lazily (`AddLazyGlobal`, #2805) or selectively via `Prepared<T>.ReferencedGlobals` (#2831). The two compose with the snapshot. **Interop** - CLR member accessors are shared process-wide (#2798, made effective for extension-method hosts in #2829); compiled lanes cover dictionary writes, indexers, statics and omitted optional arguments (#2839); host delegates invoke through arity-typed thunks with no argument array (#2799, #2843). - Typed converter registration (#2794) and `EnumConversionMode.Name` (#2796) keep the lanes a blanket converter used to cost. - JSON parses from char and UTF-8 spans (#2832) and serializes into `IBufferWriter<byte>` (#2822). - `NullPropagatingReferenceResolver.Instance` (#2833) makes nullish member reads yield `undefined` through a recognized inline lane. **Performance, gated** - Against 4.14.0 on idle hardware: **every Dromaeo row improved** (`object-regexp` −25% with 48% fewer allocations, `object-string` −21%, `string-base64` −15%); SunSpider improved on eleven scripts, zero regressions. - Fast-call coverage widened across dozens of built-ins, with per-argument guards and register-based rest calls: `Math.max(a,b)` −22%, `push(x,y)` −19% (#2828, #2843, #2844). - `encodeURI` on clean input −85%; dense `toReversed`/`with` up to −86% (#2843). On the [engine comparison benchmarks](https://github.com/sebastienros/jint/blob/main/Jint.Benchmark/README.md), Jint 4.15.0 is the fastest engine outright on 5 of 12 scripts — taking `dromaeo-object-regexp-modern` from native V8 at −42% — the fastest managed engine on 10 of 12, the fastest interpreter on all 12, and 8.9×–11.6× ahead of ClearScript (native V8) on every interop row. ## What's Changed * Replace xUnit Assert.* with AwesomeAssertions across the test suites by @lahma in sebastienros/jint#2742 * Cache interop invokers process-wide instead of per-Engine by @lahma in sebastienros/jint#2743 * Compile CLR property and field access instead of reflecting per hit by @lahma in sebastienros/jint#2744 * Convert an indexer hit by the indexer type, not the member type by @lahma in sebastienros/jint#2746 * Measure the execution timeout against an inline deadline by @lahma in sebastienros/jint#2747 * Consult reference resolver for a call to an unresolvable identifier by @poissoncorp in sebastienros/jint#2750 * Trim per-call overhead from the interop method fast lane by @lahma in sebastienros/jint#2745 * Bypass the array-covariance check on exact-JsValue[] dense element stores by @lahma in sebastienros/jint#2751 * Sort integer-index property keys by value instead of re-parsing each key by @lahma in sebastienros/jint#2752 * Bypass the array-covariance check on JsValueListBuilder element stores by @lahma in sebastienros/jint#2753 * Bypass the array-covariance check on JsObject overflow slot stores by @lahma in sebastienros/jint#2754 * Bypass the array-covariance check on callback argument arrays (sort, groupBy, array iteration) by @lahma in sebastienros/jint#2755 * Memoize the converted value of a stable reference-typed interop property by @lahma in sebastienros/jint#2756 * Replace StrictModeScope with a Strict flag on the execution context by @lahma in sebastienros/jint#2757 * CI: reliably seed the cross-OS Test262 cache and bump actions to latest by @lahma in sebastienros/jint#2758 * Bump the testing group with 2 updates by @dependabot[bot] in sebastienros/jint#2760 * Bump the analyzers group with 1 update by @dependabot[bot] in sebastienros/jint#2759 * Bump the js-engine-comparisons group with 1 update by @dependabot[bot] in sebastienros/jint#2761 ... (truncated) Commits viewable in [compare view](sebastienros/jint@v4.14.0...v4.15.3). </details> [](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>
This was referenced Aug 14, 2026
This was referenced Aug 19, 2026
This was referenced Aug 27, 2026
This was referenced Sep 7, 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.
Two additions to
Engine.AdvancedOperations, both answering friction found while adopting Jint in real hosts. They are independent; one commit each.engine.Advanced.AddLazyGlobal(name, factory, flags)Options.AddLazyGlobaldefers building a global's value until script reads the name, but it is options-time only. Squidex (Squidex/squidex#1326) computes its globals from per-request data, which it only knows afternew Engine(...), so it cannot use the registration at all. The workaround reachable from the public surface —FastSetPropertywith aCustomJsValuedescriptor of the host's own — is declined by the global-identifier inline cache, which never caches aCustomJsValuedescriptor. Lazy per-request globals were a choice between eager construction and a permanently uncacheable read.This is the options-time body invoked on a live engine: the same
LazyPropertyDescriptor, which clears itsCustomJsValueflag the moment it materializes and so rejoins the caches. Because it belongs to one engine rather than to a sharedOptions, its factory may capture engine-affine state — the case the options-time API deliberately cannot express.Neither is built for a script that never mentions the name, and
in/hasOwnProperty/Object.keys(globalThis)still see both without building anything.The one thing a post-construction install must get right
At construction time no inline cache exists yet, so an install that skipped invalidation would still be correct. On a live engine the handler trees may already hold a resolved binding for that very name, and a warmed identifier site keeps the previous descriptor by reference, revalidating it against
GlobalObject._propertiesVersionalone.SetPropertybumps that counter on every path it can take — shared-layout slot replacement, hybrid side-dictionary add, plain dictionary store, deopt fallback — which is why the install goes through it rather than writing_propertiesdirectly. It correctly leaves_lexicalMutationsand_envBindingInjectionEpochalone: a global property is not a lexical binding and injects nothing into an existing environment, and bumping either would be invalidating caches for a change that did not happen.That is not theoretical. A deliberately naive first implementation that stored straight into
_propertiesfailed three of the tests below: a warmed eager global and a warmed built-in both kept serving their old value forever, and the version-bump assertion failed on the first storage path it reached.Snapshot interplay, stated as it falls out
Pinned rather than papered over, and documented on the method:
The second follows from the
IFieldBackedLazyDescriptorcontract — restore rewrites_flags/_value, which re-arms the lazy. It is the desirable behaviour rather than an artifact: it is what lets a pooled engine keep the laziness across evaluations. A factory whose result must survive a restore has to be installed after it.engine.Advanced.WithRestoredGlobals(snapshot, action)CaptureGlobalSnapshot/RestoreGlobalSnapshotdocument the shape every reusing host ends up writing: lock, evaluate, restore in afinally. KurrentDB (kurrent-io/KurrentDB#5690) reported that recipe as correct but easy to get wrong, and the part that gets lost is thefinally— invisible until a script throws, at which point the globals that evaluation declared are handed to the next caller. That is the exact failure the snapshot API exists to prevent.It is
try { action(); } finally { RestoreGlobalSnapshot(snapshot); }and nothing else. Both arguments are null-checked; everything after that is left toRestoreGlobalSnapshot's own guards rather than duplicated, so a foreign snapshot or an outstanding asynchronous evaluation fails exactly as documented — after the action has run, since this adds afinallyand does not pre-validate on the restore's behalf. A test pins that ordering so it is a stated property rather than an accident.The doc deliberately does not oversell it: identical non-guarantees to
RestoreGlobalSnapshot, linked rather than restated. It is still a configuration-reuse primitive and not an isolation boundary — afinally, not a sandbox.Tests
Red-test-first throughout. Integrator-facing tests live in
Jint.Tests.PublicInterface, the project withoutInternalsVisibleTo, so they prove the surface is genuinely reachable by a third party.Jint.Tests.PublicInterface/LazyGlobalRegistrationTests.cs(+11): a site that already failed to resolve the name, a warmed eager global and a warmed built-in each observe the new binding (identifier and member reads, on a sharedPrepared<Script>so the second evaluation reaches the node the first warmed); the factory runs lazily and at most once; existence and enumeration do not materialize; flags honoured; a factory capturing engine-affine state; a null factory result becomingundefinedrather than re-running; both snapshot directions; null arguments.Jint.Tests.PublicInterface/GlobalSnapshotTests.cs(+6): restore observed after normal completion, after a CLR throw from the action, and after aJavaScriptExceptionfrom the script; restore runs exactly once and the engine stays usable; null arguments; the guard-ordering pin.Jint.Tests/Runtime/LazyGlobalInstallationTests.cs(new): the internals the public surface cannot see — which descriptor is stored and that it is stored unmaterialized, the version bump on all four storage paths, the two lexical counters asserted unchanged, and theIFieldBackedLazyDescriptormarker that makes the restore able to revert it.Docs: the public-contract table in
AGENTS.mdgains both APIs plus a gotcha stating the invalidation invariant for any future post-construction global installer, andREADME.md's embedding section covers both.Gate
dotnet build -c Releaseclean (TreatWarningsAsErrors),Jint.Tests4592 net10.0 / 4512 net472,Jint.Tests.PublicInterface1189 × 2 frameworks,Jint.Tests.Test26299441 passed / 0 failed. All green.