Skip to content

Async entries: one failure channel, decided by the failure and not by a thread race - #3252

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3241-evaluateasync-faulted-task
Aug 22, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3241-evaluateasync-faulted-task

Conversation

@lahma

@lahma lahma commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Closes #3241.

Engine.EvaluateAsync could 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 writing var 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 *Async entry showed 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 reached the task. EvaluateAsync and InvokeAsync were 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

A usage error is thrown out of the call; everything the operation does is delivered through the Task.

Synchronous — usage errors only. A null argument, a Prepared<Script> that did not come from PrepareScript, and the concurrent-use InvalidOperationException. 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 what Advanced.WaitForScheduledWorkAsync does, and HostEngineConcurrencyTests.ConcurrentAsyncEntryIsRejectedWithoutInterruptingTheOwner already pinned it for EvaluateAsync.

Task — everything else. Parse errors, script throws, every constraint, promise rejections. The operation started and failed, and the host that wrapped its await must see it.

Two judgement calls worth naming:

  • Parse errors move to the task, though they never raced. Leaving them synchronous would force a host to write both a try around the call and one around the await — the worst of both worlds. A syntax error is work the operation did, not an argument check.
  • Argument validation had to be made synchronous, not merely left so. The string overloads had no null check at all — code.Length NRE'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 async body owning the release in a finally. A 17-line INVARIANT comment 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

sibling outcome
EvaluateAsync ×2, InvokeAsync ×2 fixed — shared the shape exactly
ExecuteAsync, Modules.ImportAsync admission hoisted to synchronous, for the rule
JsValue.UnwrapIfPromiseAsync fixed — body now on the task
Advanced.WaitForScheduledWorkAsync already conformed; it was the in-repo precedent
Modules.StartImport returns an operation, not a Task — n/a
CallAsync does not exist; the issue named it speculatively

Public behaviour changes — all deliberate, all listed

  1. EvaluateAsync and InvokeAsync now fault the task for parse errors, script throws and every constraint failure. This is the fix.
  2. ExecuteAsync and Modules.ImportAsync now throw the concurrent-use InvalidOperationException synchronously, 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.
  3. EvaluateAsync(null) / ExecuteAsync(null) / InvokeAsync(null, …) now throw ArgumentNullException where they threw NullReferenceException from inside the parser. Strictly an improvement, but it is a type change.

No signature changed anywhere. ExecuteAsync/ImportAsync losing the async keyword 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:

pre-fix post-fix
forced-ordering case synchronous throw, 100% faulted task, 100%

Repetition of the issue's own scenario, 6000 runs per process:

sync throws task faults
pre-fix, idle machine 1 / 6000 5999
pre-fix, loaded machine 5 / 6000 5995
post-fix 0 / 6000 6000

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.Run captures it instead, which is why the shipped test uses Task.Run + Wait().

The constraint is unchanged

TheTaskCarriesTheSameExceptionTheSynchronousEntryThrows runs one script through Evaluate and through EvaluateAsync and asserts same type and Message string-equal, then asserts the script did not run past the trip point. TheBudgetStillFiresWhenTheFailureOnlyReachesTheTask writes the exact host shape from the issue — call unwrapped, catch around the await only — 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 catch clause was added anywhere — the old catch { Release; throw; } became finally { Release; }, which is propagation-identical. Nothing can be swallowed that could not be before.

The named test now asserts the channel

TransferredHostCallbackCountsAllocationsOnItsThread had its EvaluateAsync(…) call outside Record.ExceptionAsync, which is what let the raw exception escape. It now wraps the call in Should().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.Tests 10342 (net10.0) / 7206 (net472), identical with JINT_HOST_CONTRACT_VERIFICATION=1. Jint.Tests.PublicInterface 2427 / 1899, and 2431 / 1903 verifying. New HostAsyncFailureChannelTests: 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/main baseline run on the same machine — identical, no movement.

Documentation: an AGENTS.md contract-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 the catch-around-the-await example.

Left undone

  • Advanced.WebApi.InvokeFetchHandlerAsync / CopyReadableStreamAsync are async throughout, so everything — including their own ArgumentNullException — 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.
  • The synchronous Evaluate/Execute/Invoke still NRE on a null script. Only the async entries gained validation, because only they have a before-body/after-body distinction to keep true.
  • One unreachable edge: if EnterHostCall itself threw, its InvalidOperationException would now arrive via the task. It can only throw if another thread claimed _ownerThreadId between the successful reservation and the claim, which the reservation's own double-check makes effectively unreachable.

🤖 Generated with Claude Code

… 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
lahma merged commit 07a6dc1 into sebastienros:main Aug 22, 2026
5 checks passed
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>
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.

EvaluateAsync can surface a constraint failure synchronously, so the failure channel depends on a thread race

1 participant