Skip to content

Re-check amortized constraints at interpreter/host-code boundaries - #2713

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:amortized-constraints-host-boundary
Jul 20, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:amortized-constraints-host-boundary

Conversation

@lahma

@lahma lahma commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Discussion #2707 reports that since #2672 a CancellationToken (or timeout) can go unobserved when a loop's time is spent inside host (CLR) calls:

var engine = new Engine(o => o.CancellationToken(cts.Token));
engine.SetValue("sleep", (Action<int>)(ms => Thread.Sleep(ms)));
cts.CancelAfter(TimeSpan.FromMilliseconds(200));
engine.Execute("for (let i = 0; i < 40; i++) { sleep(100); }"); // pre-fix: runs ~4 s to completion

The amortization introduced in #2672 bounds time/cancellation detection latency in statement count (64 statements). That is a valid wall-clock proxy only while statements stay cheap; a statement that calls user CLR code can take arbitrarily long, so a loop of slow host calls stretches the 64-statement window to minutes.

Fix

Re-check the amortized constraints when control returns from user CLR code to the interpreter. Detection latency is again bounded by one host call, as it was pre-#2672 (which checked before every statement). The mechanism's boundaries are deliberate:

  • Only after the host call returns, never on entry — an entry check would block host cleanup calls (e.g. a release-the-lock delegate inside a JS finally block) once cancellation is pending.
  • Only while script evaluation is active — constraint state is only meaningful inside an Execute/Invoke window: the time constraint's CTS is re-armed at the end of every run and keeps counting while the engine is idle, and a token cancelled during normal teardown must not make later C#-side reads of wrapped objects throw.
  • Not in debug mode — a debugger paused longer than the timeout would otherwise deterministically fail every interop read in watch/conditional-breakpoint evaluation.
  • Awaitable delegate results reach promise conversion before the check, so an in-flight Task always gets its continuation attached and is never dropped unobserved.

Covered boundaries:

  • DelegateWrapper — user delegates registered via SetValue (the discussion's repro)
  • MethodInfoFunction — method calls on wrapped CLR objects
  • TypeReference.Construct — all three creation branches (reflected constructors, Options.Interop.CreateTypeReferenceObject factory, Activator value-type path)
  • ReflectionAccessor.GetValue/SetValue — ObjectWrapper property/field/indexer reads and writes
  • The dictionary interop lane (TryGetDictionaryValue/TrySetDictionaryValue on wrapped IDictionary<,>)
  • The wrapped-IEnumerable iterator lane (MoveNext in for-of)
  • The Options.Interop.MemberAccessor callback

Intentionally not instrumented: built-in ClrFunction dispatch (would reintroduce the per-call cost on hot built-ins that #2672 removed; long-running built-ins self-check via Engine.ConstraintCheckInterval), operator-overload resolution (its catch-all would launder constraint exceptions into TargetInvocationException), and user-supplied converter/factory callbacks — embedder-authored code hosting long operations can observe the CancellationToken itself.

Why not an option to disable/tune the amortization?

Considered and rejected: an interval knob would force embedders to trade performance for correctness and leave the default behavior broken for the reported scenario. The boundary re-check restores the wall-clock bound automatically with no new API surface. The checks are volatile-bool reads (~1–2 ns) on paths that already do reflection/delegate dispatch (hundreds of ns to µs); unconstrained engines pay only an empty-array length check.

Verification

The discussion repro now throws ExecutionCanceledException after ~290 ms — the 200 ms CancelAfter plus the in-flight 100 ms sleep whose return observes the cancellation (pre-fix: runs the full ~4 s to completion).

12 new tests in ExecutionConstraintTests:

  • Deterministic cancellation tests (token cancelled from inside the 3rd host call, exactly 3 calls asserted) for: delegate calls in a top-level loop and in the tight for-body lane, CLR method call, property read, property write, CLR constructor, dictionary read, and for-of iteration.
  • A lone-host-call test that isolates the post-invoke check (no further host call or countdown follows, so only the check at that call's own return can observe the cancellation).
  • Regression guards: host-side C# reads of wrapped objects must not throw after post-execution cancellation or after the idle-engine time budget expires, and debug-mode engines keep the pre-existing countdown-only behavior.

Full Jint.Tests, Jint.Tests.PublicInterface, Jint.Tests.CommonScripts and Test262 suites pass on net10.0 and net472.

Benchmarks

InteropLambdaBenchmark.ForLoop (hot wrapped-object property reads — ClrObject exercises ReflectionAccessor.GetValue, Dictionary exercises the newly covered dictionary lane), same-machine back-to-back A/B:

Type main this PR
ClrObject 14.015 μs 13.382 μs
JsonNode 36.586 μs 34.178 μs
Dictionary 23.889 μs 22.016 μs
JsValue 9.691 μs 9.872 μs

Deltas are within run-to-run variance (signs flip across measurement windows) and allocations are byte-identical — expected, since an unconstrained engine pays only an empty-array length check per boundary and a constrained one two volatile bool reads.

Fixes the scenario reported in #2707.

🤖 Generated with Claude Code

The amortization from sebastienros#2672 bounds timeout/cancellation detection
latency in statement count (64), which tracks wall-clock time only
while statements stay cheap; a statement that calls user CLR code can
take arbitrarily long, so a loop of slow host calls could stretch the
detection window to minutes (discussion sebastienros#2707).

Interop call sites that hand control to user CLR code (delegates,
wrapped methods, property/field/indexer accessors, CLR constructors)
now re-check the amortized constraints on entry and after the host
code returns, bounding detection latency by a single host call again.
Built-in ClrFunction dispatch stays check-free by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) July 20, 2026 18:34
@lahma
lahma merged commit 3dead8b into sebastienros:main Jul 20, 2026
7 of 8 checks passed
legrab added a commit to legrab/pocok that referenced this pull request Jul 29, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.13.0 to
4.14.0.

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

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

## 4.14.0

Jint 4.14.0 is an **interop-focused performance release**: CLR arrays
now cross into script as live views instead of copies, recently wrapped
host objects reuse their wrappers, single-candidate interop method calls
dispatch through compiled invokers, and `JSON.parse` interns repeated
keys and values. Host collection traversal is **10.9× faster** than
4.13.0. **Two interop defaults changed in this release** — read the
first two highlights if you pass CLR arrays to scripts or rely on
per-crossing conversion behavior; everything else needs no code changes
to benefit.

### Highlights

**CLR arrays are live views by default (behavior change).**
`Options.Interop.ArrayConversion` now defaults to
`ArrayConversionMode.LiveView` (#​2721, #​2728, #​2735): a single-rank
`T[]` crossing into script becomes a live, fixed-size view over the
underlying array — the way wrapped `List<T>` already behaves — instead
of being copied into a new JS array on every read. Writes go through in
both directions, and arrays exposed through read-only-declared members
(e.g. `IReadOnlyList<T>`) produce read-only views. Iteration,
`Array.prototype` methods, JSON serialization, index-key enumeration
(`Object.keys` / `for..in` yield `"0"`..`"n-1"`) and `undefined` for
out-of-range reads all behave array-like, but `Array.isArray` returns
`false`, and because CLR arrays are fixed-size, resizing operations
(`push`/`pop`/`length` writes) throw a `TypeError` like integer-indexed
exotic objects do — `shift`/`splice` may move elements before their
length change throws, as for typed arrays. Set
`Options.Interop.ArrayConversion = ArrayConversionMode.Copy` to restore
the 4.13 behavior.

**Recently wrapped CLR objects reuse their wrappers (behavior change).**
The new `Options.Interop.CacheRecentObjectWrappers` defaults to `true`
(#​2734): a small bounded ring (8 entries, keyed by reference identity
and exposed type) reuses wrappers for host objects that repeatedly cross
into script. Wrapper identity becomes stable (`host.Obj === host.Obj`),
script-attached state (freeze, `defineProperty`, expandos) survives
crossings, and the per-crossing wrapper allocation disappears. Under
`Copy` array conversion this also means repeated reads of the same CLR
array reuse the first `JsArray` snapshot while it stays cached —
CLR-side mutations are not re-copied; set the option to `false` for the
pre-4.14 fresh-snapshot-per-crossing behavior. `Engine.Dispose()`
releases the ring.

**Interop fast lanes.** Single-candidate method calls run through a
compiled invoker that binds and invokes without argument arrays or
boxing (#​2733), with per-parameter binding flags precomputed (#​2719).
Resolved `ObjectWrapper` members get a per-call-site inline cache
(#​2722) and the member-call fast path covers primitive string receivers
(#​2717). Array-like wrapper creation is a cached factory call with
lazily materialized `length` (#​2730), primitive elements convert
without boxing on both indexed reads and `Array.prototype` iteration
(#​2731, #​2735), the wrapper identity caches cover CLR arrays (#​2716),
and implicitly implemented interface methods are deduplicated in member
resolution (#​2711).

**JSON.** `JSON.parse` interns property keys and string values within a
parse, parses numbers off the span with an exactly-rounded fast path and
scans string content in bulk (#​2718, #​2725, #​2732) — the
`json-parse-modern` comparison row is 6% faster with 23% less allocation
than 4.13.0. Parsing is also aligned with the JSON grammar (#​2738):
malformed numbers like `-09` and `1.` are now rejected as in V8, while
raw U+2028/U+2029 in strings and escaped control characters in keys —
both valid JSON — are now accepted.

**Strings.** Chained `slice`/`substring` and `split` segments stay
zero-copy views (#​2720), whole-string `substring`/`substr` return the
receiver, and mismatched-length comparisons no longer materialize views
(#​2740).

**Execution constraints at host boundaries.** Timeouts and cancellation
are re-checked when control returns from host CLR code, so detection
latency is bounded by one host call instead of a statement-count window,
without adding per-statement cost — gated on execution depth so
host-side reads of wrapped objects on an idle engine never observe a
stale timer (#​2713, #​2714, #​2715). Execution-context depth stays
balanced when constraint exceptions unwind generator/async frames, and a
host callback that re-enters the engine no longer resets the outer
script's budget (#​2736).

**Correctness (including a pre-release review).** A review of everything
since 4.13.0 fixed: spurious TDZ when a for-header reads a name the loop
body shadows (#​2709) and stale closure captures from destructuring
defaults in for-loop headers (#​2739); the compiled-invoker lane now
defers to custom `ITypeConverter`s and preserves reflection exception
types (#​2737); and the new wrapper defaults were hardened —
declared-type contracts for arrays (an `IReadOnlyList<T>`-typed member
no longer yields a writable view), a static type-mapper poisoning crash,
`Engine.Dispose` releasing the wrapper caches, and JS-array
`in`/enumeration/out-of-range semantics on array views (#​2735). Closure
reads memoize slot-cache chain reachability (#​2726).

On the [engine comparison
benchmarks](https://github.com/sebastienros/jint/blob/main/Jint.Benchmark/README.md),
Jint 4.14.0 beats ClearScript (native V8) by 7.1×–9.1× on every script ↔
host interop row — host collection traversal went from last to second
among all engines at 15,597 → 1,433 µs with 99% less allocation — while
remaining the fastest managed engine on 10 of 12 pure-JS scripts and the
fastest interpreter on all 12, and now leading `array-stress` and
`dromaeo-object-array`, rows V8 narrowly led at 4.13.0.


## What's Changed
* Refresh EngineComparison benchmarks for 4.13.0 by @​lahma in
sebastienros/jint#2704
* Fix spurious TDZ when a for-header reads a name the loop body shadows
by @​svenrog in sebastienros/jint#2709
* Bump the testing group with 1 update by @​dependabot[bot] in
sebastienros/jint#2710
* Add tests for using modules from script code run via Evaluate by
@​lahma in sebastienros/jint#2712
* Deduplicate implicitly implemented interface methods in member
resolution by @​lahma in sebastienros/jint#2711
* Re-check amortized constraints at interpreter/host-code boundaries by
@​lahma in sebastienros/jint#2713
* Add ClearScript V8 to engine comparison benchmarks, trim suite, add
script-to-host interop suite by @​viceice in
sebastienros/jint#1775
* Gate host-boundary constraint checks on active evaluation and harden
coverage by @​lahma in sebastienros/jint#2714
* Key the host-boundary constraint gate on execution depth and close
remaining lanes by @​lahma in
sebastienros/jint#2715
* Cover CLR arrays with the interop identity caches by @​lahma in
sebastienros/jint#2716
* Extend the member-call fast path to primitive string receivers by
@​lahma in sebastienros/jint#2717
* Intern object property keys within a single JSON parse by @​lahma in
sebastienros/jint#2718
* Precompute per-parameter interop binding flags by @​lahma in
sebastienros/jint#2719
* Keep slice-of-slice and split segments zero-copy by @​lahma in
sebastienros/jint#2720
* Add opt-in ClrArrayConversion.LiveView interop mode for CLR arrays by
@​lahma in sebastienros/jint#2721
* Cache resolved ObjectWrapper members per member-expression node by
@​lahma in sebastienros/jint#2722
* Refresh engine comparison README after the V8-gap campaign by @​lahma
in sebastienros/jint#2723
* Bulk string scanning and a simple-number fast path for JSON.parse by
@​lahma in sebastienros/jint#2725
* Memoize slot-cache chain reachability for closure reads by @​lahma in
sebastienros/jint#2726
* Refresh engine comparison tables after the second campaign round by
@​lahma in sebastienros/jint#2727
* Default Interop.ArrayConversion to LiveView for 4.14 by @​lahma in
sebastienros/jint#2728
* Cache array-like wrapper factories and materialize length lazily by
@​lahma in sebastienros/jint#2730
* Convert primitive array-like wrapper elements without boxing by
@​lahma in sebastienros/jint#2731
* Add a compiled-invoker fast lane for single-candidate interop method
calls by @​lahma in sebastienros/jint#2733
* Intern JSON.parse string values and parse numbers off the span by
@​lahma in sebastienros/jint#2732
* Default Interop.CacheRecentObjectWrappers to true for 4.14 by @​lahma
in sebastienros/jint#2734
* Harden LiveView array wrappers and interop wrapper caches for 4.14 by
@​lahma in sebastienros/jint#2735
* Keep execution-context depth balanced under raw constraint exceptions
by @​lahma in sebastienros/jint#2736
 ... (truncated)

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

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.13.0&new-version=4.14.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>
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