Repository navigation
Async entries: one failure channel, decided by the failure and not by a thread race - #3252
Merged
lahma merged 1 commit intoAug 22, 2026
Merged
Conversation
… a thread race
`Engine.EvaluateAsync` could report an execution-constraint failure out of the call
itself rather than through the returned task, and which of the two happened was
decided by thread scheduling. `MemoryLimitConstraint` charges a host callback running
on another thread to the same operation, so if that charge landed before the engine
thread reached `ScriptEvaluation`'s post-script check, the check tripped inside
`EvaluateAsync`'s synchronous phase - where no task exists yet - and the `catch
{ Release...; throw; }` rethrew on the caller's stack. A host writing
var pending = engine.EvaluateAsync(source);
...
await pending; // the catch is here
wraps the await, not the call, so under load its budget failure bypassed the handler
entirely. Measured on the parent commit: 1 synchronous throw in 6000 runs on an idle machine and
5 in 6000 on a loaded one - the race widens under contention - of
`HostMemoryLimitTests.TransferredHostCallbackCountsAllocationsOnItsThread`, which is
what has been flaking `windows`/`net472` and the host-contract-verification leg on
unrelated PRs. Nothing in the interpreter distinguishes the two outcomes, so no host
could have written a `catch` against them.
The family was already split, by nothing more principled than which methods happened
to be declared `async`. `ExecuteAsync` and `Modules.ImportAsync` were, so their whole
body - parse, run, constraint check - reached the returned task. `EvaluateAsync` and
`InvokeAsync` were not, so the identical failure on the identical script erupted from
the call. Both halves of that split were accidental.
The rule this settles on, stated once and applied to every public `*Async` entry:
* Everything the operation itself does - parsing, running the script, an execution
constraint tripping, a promise rejecting - is delivered through the returned
`Task`. It says the operation started and failed, so a host that wrapped its
`await` must see it.
* Only a usage error is thrown out of the call: a `null` argument, a
`Prepared<Script>` that did not come from `PrepareScript`, and the
`InvalidOperationException` refusing the call because the engine is already in
use. Those say the operation never started, so there is no evaluation for a task
to describe. This is the exception the TAP guidance carves out, it is already what
`Advanced.WaitForScheduledWorkAsync` documents and what
`HostEngineConcurrencyTests.ConcurrentAsyncEntryIsRejectedWithoutInterruptingTheOwner`
pins, and it stays synchronous here.
Mechanically each entry now validates, reserves, and hands off to a `private async`
body that owns the release in a `finally`; an `async` body captures its own
synchronous phase, which is exactly what puts the parse and the whole synchronous run
of the script on the task. `Engine.Async.cs` carries the invariant as a comment so the
split is not "simplified" away again.
No constraint is weakened. Every failure keeps its exception type and its message,
still aborts the run, and still bounds the same work - only the channel changed, and
only from "whichever thread got there first" to "always the task".
Public behaviour changes, all deliberate:
* `EvaluateAsync` (both overloads) and `InvokeAsync` now fault the returned task for
parse errors, script throws and every constraint failure, instead of throwing
synchronously.
* `ExecuteAsync` and `Modules.ImportAsync` now throw the concurrent-use
`InvalidOperationException` synchronously, instead of faulting the task, which is
what makes the usage-error half of the rule true for the whole family.
* `EvaluateAsync(null)`, `ExecuteAsync(null)` and `InvokeAsync(null, ...)` now throw
`ArgumentNullException` where they previously threw `NullReferenceException` from
inside the parser. Validating before the reservation is what keeps argument
validation on the synchronous side of the rule rather than accidentally there.
`HostAsyncFailureChannelTests` pins the rule in both directions - twenty cases, of
which fifteen fail against the parent commit - and includes a forced-ordering
reproduction of the cross-thread charge, so the case that used to need six thousand
runs to observe now fails deterministically when the channel regresses.
`TransferredHostCallbackCountsAllocationsOnItsThread` asserts the channel rather than
only the exception type, which is what let the raw exception escape it before.
Fixes sebastienros#3241
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmK1CzJSgMd1LyTEMX3yiE
lahma
added a commit
to lahma/jint
that referenced
this pull request
Aug 23, 2026
…lly has Main has moved 181 commits past v4.16.0 and nothing records what an embedder has to react to. Most of it is invisible to a compiler: every entry below still compiles exactly as it did in 4.16 and behaves differently at run time, and six of them flip a default. `docs/v5-migration.md` is the artefact those entries go in. It is deliberately not a second README: a table per change, before/after code where it helps, and the rationale left in the pull request it cites. Seeded from git history, every claim checked against the tree rather than against the pull request title: * sebastienros#3054 `Interop.AllowWrite` true -> false. A projected CLR write is now silently ignored in sloppy mode and a TypeError in strict mode. * sebastienros#3056 `Interop.ArrayConversion` LiveView -> Copy. `Array.isArray` flips to true, `push`/`length` stop throwing, and CLR-side mutations after the crossing stop being visible. * sebastienros#3057 `Constraints.StackOverflowGuard` false -> true. RangeError instead of a process that ends with no exception in the log. * sebastienros#3058 `AgentCanSuspend` true -> false. `Atomics.wait` is a TypeError before a waiter is registered; `waitAsync` is untouched. * sebastienros#3052 namespace type discovery loses its implicit fallback to `Assembly.GetCallingAssembly()` / `GetExecutingAssembly()` / `Type.GetType(name)` and becomes the `AllowedAssemblies` allow-list. * sebastienros#3051 host exception, module-load and CLR-resolution messages are redacted from script; `ExposeDetailedErrors()` restores all three. * sebastienros#3035 concurrent `Engine` use fails fast, and an engine stays reserved for the lifetime of a returned async Task. * sebastienros#3036 `LimitMemory` charges allocations across async continuations, so a budget can now trip where one synchronous segment never reached it. * sebastienros#3252 every `*Async` entry reports the operation's failures through the task and only a usage error out of the call. * sebastienros#3248 an array-like `length` above 2^32-1 stops answering differently per target framework. * sebastienros#3037, sebastienros#3045, sebastienros#3046 add parser, module-graph and result bounds that all default to unlimited, so they change nothing until configured; sebastienros#3059 and sebastienros#3060 add the diagnostics and the hardened profile on top. Two entries the prompt for this work had slightly differently, both checked and written as the tree has them: the stack-overflow guard raises a catchable `RangeError`, not a `RecursionDepthOverflowException`, and `ArrayOperations` is internal, so sebastienros#3248 removes no public member — its break is what a script sees. Sections 2, 3 and 6 (removed API, renamed API, AOT) are explicit empty placeholders. Nothing public has been removed or renamed since v4.16.0, and a parallel task is measuring the AOT state. The target-framework section is written and marked pending: `Jint.csproj` still lists net462, and the net472 change is not on main yet. `Jint/AGENTS.md` gains the rule that makes the guide stay current — a change to anything in its public-contract table is a row in the guide, in the same pull request, including a change that breaks nothing at compile time. The root `AGENTS.md` is untouched; it is at 23 KB against a 24 KiB budget. README's "Branches and releases" described `main` alone and did not mention the `3.x` branch at all. It now has a row per live branch, in the same wording sebastienros#3291 gives the 4.x branch's own copy, plus a pointer to the guide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
added a commit
that referenced
this pull request
Aug 23, 2026
…lly has (#3294) Main has moved 181 commits past v4.16.0 and nothing records what an embedder has to react to. Most of it is invisible to a compiler: every entry below still compiles exactly as it did in 4.16 and behaves differently at run time, and six of them flip a default. `docs/v5-migration.md` is the artefact those entries go in. It is deliberately not a second README: a table per change, before/after code where it helps, and the rationale left in the pull request it cites. Seeded from git history, every claim checked against the tree rather than against the pull request title: * #3054 `Interop.AllowWrite` true -> false. A projected CLR write is now silently ignored in sloppy mode and a TypeError in strict mode. * #3056 `Interop.ArrayConversion` LiveView -> Copy. `Array.isArray` flips to true, `push`/`length` stop throwing, and CLR-side mutations after the crossing stop being visible. * #3057 `Constraints.StackOverflowGuard` false -> true. RangeError instead of a process that ends with no exception in the log. * #3058 `AgentCanSuspend` true -> false. `Atomics.wait` is a TypeError before a waiter is registered; `waitAsync` is untouched. * #3052 namespace type discovery loses its implicit fallback to `Assembly.GetCallingAssembly()` / `GetExecutingAssembly()` / `Type.GetType(name)` and becomes the `AllowedAssemblies` allow-list. * #3051 host exception, module-load and CLR-resolution messages are redacted from script; `ExposeDetailedErrors()` restores all three. * #3035 concurrent `Engine` use fails fast, and an engine stays reserved for the lifetime of a returned async Task. * #3036 `LimitMemory` charges allocations across async continuations, so a budget can now trip where one synchronous segment never reached it. * #3252 every `*Async` entry reports the operation's failures through the task and only a usage error out of the call. * #3248 an array-like `length` above 2^32-1 stops answering differently per target framework. * #3037, #3045, #3046 add parser, module-graph and result bounds that all default to unlimited, so they change nothing until configured; #3059 and #3060 add the diagnostics and the hardened profile on top. Two entries the prompt for this work had slightly differently, both checked and written as the tree has them: the stack-overflow guard raises a catchable `RangeError`, not a `RecursionDepthOverflowException`, and `ArrayOperations` is internal, so #3248 removes no public member — its break is what a script sees. Sections 2, 3 and 6 (removed API, renamed API, AOT) are explicit empty placeholders. Nothing public has been removed or renamed since v4.16.0, and a parallel task is measuring the AOT state. The target-framework section is written and marked pending: `Jint.csproj` still lists net462, and the net472 change is not on main yet. `Jint/AGENTS.md` gains the rule that makes the guide stay current — a change to anything in its public-contract table is a row in the guide, in the same pull request, including a change that breaks nothing at compile time. The root `AGENTS.md` is untouched; it is at 23 KB against a 24 KiB budget. README's "Branches and releases" described `main` alone and did not mention the `3.x` branch at all. It now has a row per live branch, in the same wording #3291 gives the 4.x branch's own copy, plus a pointer to the guide. Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Closes #3241.
Engine.EvaluateAsynccould surface a constraint failure synchronously — out of the call itself — rather than through the returned task, decided by a thread race the host cannot see. A host writingvar t = engine.EvaluateAsync(src); … await t;reasonably wraps the await, not the call, and under load its budget failure bypassed that handler entirely.This has blocked CI on two unrelated PRs today.
The finding that reframed the issue
Probing every failure mode of every
*Asyncentry showed the family was already split — by nothing more principled than which methods happened to be declaredasync.ExecuteAsyncandModules.ImportAsyncwere, so their whole body reached the task.EvaluateAsyncandInvokeAsyncwere not, so the identical failure on the identical script erupted from the call. Nobody chose that.That is why the issue's direction 2 — "leave the behaviour and document it" — was not viable: there was no single behaviour to document.
The rule
Synchronous — usage errors only. A
nullargument, aPrepared<Script>that did not come fromPrepareScript, and the concurrent-useInvalidOperationException. All three mean the operation never started, so there is no evaluation for a task to describe. This is the carve-out TAP states explicitly ("TAP methods throw exceptions out of the method call only in response to a usage error"), it is already whatAdvanced.WaitForScheduledWorkAsyncdoes, andHostEngineConcurrencyTests.ConcurrentAsyncEntryIsRejectedWithoutInterruptingTheOwneralready pinned it forEvaluateAsync.Task — everything else. Parse errors, script throws, every constraint, promise rejections. The operation started and failed, and the host that wrapped its
awaitmust see it.Two judgement calls worth naming:
tryaround the call and one around theawait— the worst of both worlds. A syntax error is work the operation did, not an argument check.code.LengthNRE'd inside the parser, i.e. in the body. Under a naive "wrap the body" fix that NRE would have moved to the task and quietly contradicted the rule.Every entry now validates → reserves → hands off to a
private asyncbody owning the release in afinally. A 17-lineINVARIANTcomment records why the split exists and says plainly: do not move a reservation into a body, and do not hoist work out of one.Sibling audit
EvaluateAsync×2,InvokeAsync×2ExecuteAsync,Modules.ImportAsyncJsValue.UnwrapIfPromiseAsyncAdvanced.WaitForScheduledWorkAsyncModules.StartImportTask— n/aCallAsyncPublic behaviour changes — all deliberate, all listed
EvaluateAsyncandInvokeAsyncnow fault the task for parse errors, script throws and every constraint failure. This is the fix.ExecuteAsyncandModules.ImportAsyncnow throw the concurrent-useInvalidOperationExceptionsynchronously, where they used to fault the task. This is the direction that makes a host wrap more, so it deserves calling out. It was chosen because the alternative left the family split, and because three of the five entries already behaved this way with a pinned test and a documented rationale — the minority moved to the majority, not the reverse. No test depended on the old behaviour.EvaluateAsync(null)/ExecuteAsync(null)/InvokeAsync(null, …)now throwArgumentNullExceptionwhere they threwNullReferenceExceptionfrom inside the parser. Strictly an improvement, but it is a type change.No signature changed anywhere.
ExecuteAsync/ImportAsynclosing theasynckeyword is an implementation detail.Evidence
Deterministic, not just statistical — a host function that awaits the pool thread's task before returning, so the callback's charge always lands before the engine thread reaches the post-script check:
Repetition of the issue's own scenario, 6000 runs per process:
The load sensitivity is the point: the window widens under contention, which is why CI hits it and a laptop usually does not.
One finding worth recording from building the repro: a first attempt using a bare
new Thread(...).Join()had the callback's own memory check trip unhandled on that thread and kill the xUnit test host outright.Task.Runcaptures it instead, which is why the shipped test usesTask.Run+Wait().The constraint is unchanged
TheTaskCarriesTheSameExceptionTheSynchronousEntryThrowsruns one script throughEvaluateand throughEvaluateAsyncand asserts same type andMessagestring-equal, then asserts the script did not run past the trip point.TheBudgetStillFiresWhenTheFailureOnlyReachesTheTaskwrites the exact host shape from the issue — call unwrapped,catcharound theawaitonly — and asserts the budget still fires.Type and message identity confirmed across the move for all five constraint kinds:
TimeoutException,StatementsCountOverflowException,MemoryLimitExceededException,RecursionDepthOverflowException,ExecutionCanceledException.No
catchclause was added anywhere — the oldcatch { Release; throw; }becamefinally { Release; }, which is propagation-identical. Nothing can be swallowed that could not be before.The named test now asserts the channel
TransferredHostCallbackCountsAllocationsOnItsThreadhad itsEvaluateAsync(…)call outsideRecord.ExceptionAsync, which is what let the raw exception escape. It now wraps the call inShould().NotThrow("a budget failure belongs on the returned task, whichever thread trips it")and then awaits — so it fails loudly on a channel regression rather than passing by luck.Verification
Jint.Tests10342 (net10.0) / 7206 (net472), identical withJINT_HOST_CONTRACT_VERIFICATION=1.Jint.Tests.PublicInterface2427 / 1899, and 2431 / 1903 verifying. NewHostAsyncFailureChannelTests: 15 of 20 cases fail against the unfixed engine, 20/20 pass with the fix.test262: 102,495 passed / 0 failed on both this branch and an
upstream/mainbaseline run on the same machine — identical, no movement.Documentation: an
AGENTS.mdcontract-table row and a Gotchas bullet stating the rule and the two "never do this" mechanics, plus a new README embedder section, Where failures arrive, with thecatch-around-the-awaitexample.Left undone
Advanced.WebApi.InvokeFetchHandlerAsync/CopyReadableStreamAsyncareasyncthroughout, so everything — including their ownArgumentNullException— reaches the task. Consistent-but-stricter and never racy, so a defensible stopping point rather than this defect. Left because Fetch: the iterator attributes, the disturbed-body lock, and WebIDL's record order #3247 was landing in that area; the follow-up is one hoist of a null check.Evaluate/Execute/Invokestill NRE on a null script. Only the async entries gained validation, because only they have a before-body/after-body distinction to keep true.EnterHostCallitself threw, itsInvalidOperationExceptionwould now arrive via the task. It can only throw if another thread claimed_ownerThreadIdbetween the successful reservation and the claim, which the reservation's own double-check makes effectively unreachable.🤖 Generated with Claude Code