Repository navigation
Make the *Async entries honour their own cancellation token - #4226
Merged
Merged
Conversation
lahma
force-pushed
the
async-entries-honour-token
branch
from
October 7, 2026 17:11
d696df5 to
b25f807
Compare
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
force-pushed
the
async-entries-honour-token
branch
from
October 7, 2026 17:13
b25f807 to
b584d1b
Compare
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>
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.
Problem
EvaluateAsync,ExecuteAsync,InvokeAsyncandModules.ImportAsynctake aCancellationTokenbut only observed it while awaiting a pending promise: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 theOptionsare 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
Optionsper tenant and gets a token per request, so to make the token actually stop a script itThrowIfCancellationRequested()itself before the call,OperationDeadlineConstraintthrough a per-engine constraint factory for no other reason,Find<OperationDeadlineConstraint>(),Begin(Timeout.InfiniteTimeSpan, token)around every call andEnd()in afinally.After this change none of that is needed; passing the token to the
*Asynccall is enough. Hosts that keep the bracket keep working (pinned).Change
*Asyncbody now callsBeginObservingAsyncEntryTokenfirst, inside itstry: an already-cancelled token cancels the returned task (OperationCanceledExceptioncarrying 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) andEndObservingAsyncEntryTokenclears it in thefinally, so no later entry, whether sync or async, observes it.EvaluationContext's single "amortized work" flag now also includes "an async entry's token is installed" (the same refresh mechanism the sampling profiler uses), andCheckAmortizedConstraints,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.ObserveCancellationfor the same token keeps gettingExecutionCanceledExceptionfrom a running script.OperationCanceledException. Before, it carried the internal linkedPromiseTimeouttoken, or none.Behaviour change (v5)
This is a documented behaviour change on
main, which the v5 line allows: new section 4.146 indocs/guide/migrating-to-v5.md. The XML docs on the entries,docs/guide/async.md, the threat model (TM-10 residual) andJint/AGENTS.mdwere updated to match. Code that passed an already-cancelled or soon-cancelled token and relied on the script finishing now getsOperationCanceledException.Hot-path note for review: per-statement code is unchanged. The amortized check (once per 64 statements) gains one token read. During an
*Asynccall with a cancellable token on an engine with no amortized constraint, the countdown now runs, at the same costObserveCancellationhas always had.Tests
All written first and run against the unfixed engine: 22 of the 24 in
Jint.Testsfailed and 3 of the 4 inJint.Tests.PublicInterfacefailed. The ones that passed are non-regression pins.Jint.Tests/Runtime/AsyncEntryCancellationTests.cs(24): pre-cancelled token runs no script for every entry (bothEvaluateAsync/ExecuteAsyncoverloads,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 withObserveCancellation.Jint.Tests.PublicInterface/HostAsyncEntryCancellationTests.cs(4): the shared-options, per-request-token shape with nothing registered; an already-cancelled request runs nothing (taskIsCanceled); an invoked handler is stopped by its request token; a host keeping itsOperationDeadlineConstraintbracket still gets its cancellation.Nothing is timed: each cancel comes from a
ClrFunctionon 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) andJint.Tests.PublicInterfacepass locally in Release.🤖 Generated with Claude Code