Skip to content

Make the *Async entries honour their own cancellation token - #4226

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:async-entries-honour-token
Oct 7, 2026
Merged

lahma merged 2 commits into
sebastienros:mainfrom
lahma:async-entries-honour-token

Conversation

@lahma

@lahma lahma commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

EvaluateAsync, ExecuteAsync, InvokeAsync and Modules.ImportAsync take a CancellationToken but only observed it while awaiting a pending promise:

  • a token already cancelled when the call was made still let the script run to completion;
  • the interpreter was never told about it, so a script that never yields (while (true) { }) could not be stopped through it at all (the XML docs said so: "does not preempt").

The documented remedy, ObserveCancellation, is fixed when the Options are built, so it cannot carry a per-request token on options shared between engines.

Embedder impact

This is the motivation: OrchardCore's JavaScript engine shares Options per tenant and gets a token per request, so to make the token actually stop a script it

  1. calls ThrowIfCancellationRequested() itself before the call,
  2. registers an OperationDeadlineConstraint through a per-engine constraint factory for no other reason,
  3. Find<OperationDeadlineConstraint>(), Begin(Timeout.InfiniteTimeSpan, token) around every call and End() in a finally.

After this change none of that is needed; passing the token to the *Async call is enough. Hosts that keep the bracket keep working (pinned).

Change

  • Each *Async body now calls BeginObservingAsyncEntryToken first, inside its try: an already-cancelled token cancels the returned task (OperationCanceledException carrying the token) before anything is parsed or run, still through the task, per the failure-channel invariant. A cancellable token is installed in engine state (Engine._asyncEntryToken) and EndObservingAsyncEntryToken clears it in the finally, so no later entry, whether sync or async, observes it.
  • The token rides the amortized cadence: EvaluationContext's single "amortized work" flag now also includes "an async entry's token is installed" (the same refresh mechanism the sampling profiler uses), and CheckAmortizedConstraints, Constraints.Check() (the self-check of long built-in loops) and interop projection read it. It is never an exact constraint, so the tight-loop lane stays armed. A non-cancellable token (default / CancellationToken.None) changes nothing: the flag and the per-statement path stay as they were.
  • It covers the synchronous run, every continuation (fast path and after a pending await), and synchronous re-entries from host callbacks. A second async entry cannot overlap; the reservation already refuses it.
  • Registered constraints are checked first, so a host that also registered ObserveCancellation for the same token keeps getting ExecutionCanceledException from a running script.
  • The promise-wait loop now hands back the caller's token on OperationCanceledException. Before, it carried the internal linked PromiseTimeout token, or none.

Behaviour change (v5)

This is a documented behaviour change on main, which the v5 line allows: new section 4.146 in docs/guide/migrating-to-v5.md. The XML docs on the entries, docs/guide/async.md, the threat model (TM-10 residual) and Jint/AGENTS.md were updated to match. Code that passed an already-cancelled or soon-cancelled token and relied on the script finishing now gets OperationCanceledException.

Hot-path note for review: per-statement code is unchanged. The amortized check (once per 64 statements) gains one token read. During an *Async call with a cancellable token on an engine with no amortized constraint, the countdown now runs, at the same cost ObserveCancellation has always had.

Tests

All written first and run against the unfixed engine: 22 of the 24 in Jint.Tests failed and 3 of the 4 in Jint.Tests.PublicInterface failed. The ones that passed are non-regression pins.

  • Jint.Tests/Runtime/AsyncEntryCancellationTests.cs (24): pre-cancelled token runs no script for every entry (both EvaluateAsync/ExecuteAsync overloads, InvokeAsync, ImportAsync); a cancel during the synchronous run stops five loop shapes (tight-loop lanes included) within the cadence; a cancel at a host-delegate boundary; a cancel in a fast-path continuation and in one resumed after a pending await; a cancel while awaiting carries the caller's token; a nested synchronous re-entry observes the token; a live token changes no result; only a cancellable token arms the cadence and only for the call; the engine is clean after a cancelled call (sync and async entries no longer see the old token, and passing it again is refused up front); a registered amortized constraint keeps the cadence; coexistence with ObserveCancellation.
  • Jint.Tests.PublicInterface/HostAsyncEntryCancellationTests.cs (4): the shared-options, per-request-token shape with nothing registered; an already-cancelled request runs nothing (task IsCanceled); an invoked handler is stopped by its request token; a host keeping its OperationDeadlineConstraint bracket still gets its cancellation.

Nothing is timed: each cancel comes from a ClrFunction on the engine thread (not a host-boundary check point), and each loop is bounded, so an engine that ignores the token fails an assertion instead of hanging.

Full Jint.Tests (net8.0, net10.0, net48) and Jint.Tests.PublicInterface pass locally in Release.

🤖 Generated with Claude Code

@lahma
lahma force-pushed the async-entries-honour-token branch from d696df5 to b25f807 Compare October 7, 2026 17:11
lahma and others added 2 commits October 7, 2026 20:12
EvaluateAsync, ExecuteAsync, InvokeAsync and Modules.ImportAsync observed
their token only while awaiting a pending promise: an already-cancelled token
still let the script run, and a script that never yields could not be stopped
through it. The token is now checked up front (the task is cancelled before any
script runs) and installed as engine state for the duration of the call, read
on the amortized constraint cadence, so the interpreter observes it without
disarming the tight-loop lane. A non-cancellable token changes nothing.

Every OperationCanceledException the entry raises now carries the caller's
token, including the one from the promise wait, which used to carry the
internal linked token or none. Registered constraints are checked first, so a
host that also used ObserveCancellation keeps its ExecutionCanceledException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reaction run under Options.Host.JobCallbacks (a then-handler, and a resumed
async body) that observes the entry's cancelled token unwinds through
HostCallJobCallback's Exit, cancels the task with the caller's token, and leaves
the engine and its hooks balanced for the next call; a pre-cancelled token
captures no job callback at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Sm5FdCDxoX4BMKdcK2CMY
@lahma
lahma force-pushed the async-entries-honour-token branch from b25f807 to b584d1b Compare October 7, 2026 17:13
@lahma
lahma merged commit f3cdbc1 into sebastienros:main Oct 7, 2026
22 of 24 checks passed
@lahma
lahma deleted the async-entries-honour-token branch October 7, 2026 18:12
lahma added a commit that referenced this pull request Oct 13, 2026
* Let every part of an *Async call observe its cancellation token

The token an *Async entry is handed now travels with the engine's async reservation:
ReserveAsyncHostOperation installs it and whichever release drops the reservation clears it, so a
host callback still finishing under the call keeps observing it. Every script-running entry -
EvaluateAsync, ExecuteAsync, InvokeAsync, Modules.ImportAsync, UnwrapIfPromiseAsync and
WebApi.InvokeFetchHandlerAsync - shares one RunOnReservationAsync body that refuses an
already-cancelled token before anything runs.

Work that outlives the call captures the token once beside the registered constraint's
(EngineCancellation) and links the raw tokens into a source it owns: fetch, XMLHttpRequest,
WebSocket, EventSource, AsyncModuleLoader loads and ModuleLoadCompletion's propagation rule, the
blocking drain behind UnwrapIfPromise/Modules.Import, and the WaitForScheduledWork pump. The
settlement loop re-checks the token right after its wait, and no longer rewraps cancellations it
did not raise.

Docs (async guide, TM-10, the #4226 migration section) now state what the token reaches and what it
does not, including the queued jobs a cancelled call leaves behind and the snapshot restore that
clears them. Adds AsyncEntryTokenBenchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Sm5FdCDxoX4BMKdcK2CMY

* Say in Constraints/AGENTS.md that the I/O operations also observe the *Async call's token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Sm5FdCDxoX4BMKdcK2CMY

---------

Co-authored-by: Claude Opus 5.5 <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.

1 participant