Skip to content

Let every part of an *Async call observe its cancellation token - #4260

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:async-entry-token-followup
Oct 13, 2026
Merged

lahma merged 2 commits into
sebastienros:mainfrom
lahma:async-entry-token-followup

Conversation

@lahma

@lahma lahma commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #4226. A review found that an *Async call's token reached only the interpreter, while the docs said it covered the whole call. This PR makes the code match those docs, then rewrites the docs to say exactly what the code now guarantees.

The design: one token, carried by the reservation

  • The reservation owns the token. ReserveAsyncHostOperation(entryToken: …) installs it in Engine._asyncEntryToken. Whichever release actually drops the reservation clears it: ReleaseAsyncHostOperation, or ReleaseHostCallbackAdmission once the last callback admitted under the reservation finishes. The token therefore lives exactly as long as the reservation.
  • One shared body. Every script-running entry runs on the same body, RunOnReservationAsync. It refuses an already-cancelled token before anything runs, so a new entry cannot forget the check. The entries are EvaluateAsync and ExecuteAsync (both overloads each), InvokeAsync, UnwrapIfPromiseAsync, Modules.ImportAsync and WebApi.InvokeFetchHandlerAsync.
  • Work that outlives the call captures the token once. It calls Engine.CaptureCancellation(), which returns an internal EngineCancellation: the entry's token beside a registered CancellationConstraint's. The work then links the raw tokens into a source it owns and disposes itself, never one somebody else may have disposed. A linked source is allocated only when more than one token can fire.

Findings

# Finding Change
1 The docs overpromise FetchOperation, XhrOperation, WebSocketOperation and EventSource (captured once per object, so a reconnect after the call still observes it) all use the captured cancellation. So do AsyncModuleLoader (it hands LoadModuleContentsAsync the completion's token), ModuleLoadCompletion (it owns and disposes the linked source when it settles), MustPropagateLoaderException (a loader's OperationCanceledException propagates once either token has fired), DrainEventLoopUntil, and the WaitForScheduledWork pump. Rewritten: docs/guide/async.md, TM-10, the #4226 migration section, the XML remarks, and the InvokeFetchHandlerAsync paragraph in fetch-and-networking.md.
2 Nested synchronous waits ignore the token The drain and the pump link the entry token and check it before every turn. A woken wait throws OperationCanceledException carrying the entry token; a registered constraint still wins, as it does in CheckAmortizedConstraints.
3 UnwrapIfPromiseAsync and InvokeFetchHandlerAsync skip the token Both now reserve with the token and share RunOnReservationAsync, so the fetch handler's own synchronous run is cancellable too.
4 The token is cleared before the reservation is released The Begin/End pair is gone; the token is installed and cleared with the reservation.
5 The settlement loop runs continuations after a cancelled wait The token is re-checked right after the wait, before any continuation runs.
6 ThrowIfAsyncEntryCancelled reads the field twice It reads the field once.
7 The catch filter rewraps cancellations it did not raise Removed. The wait's own cancellation is now only a wake, and the tokens are checked explicitly afterwards, so the exception carries the caller's token from the start. A host callback's own OperationCanceledException keeps its identity. The PromiseTimeout catch is narrowed to the wait's own token.
8 A cancelled call leaves queued jobs behind Documented, not changed in code, and no new API. The async guide, TM-10, the migration section and the XML remarks state the behaviour and name the existing remedy: Engine.Advanced.RestoreGlobalSnapshot, or discarding the engine. Two public tests pin both halves.
9 The migration guide is incomplete "What could break" gains four entries. (1) A pre-cancelled token fails with OperationCanceledException even for a host that also registered ObserveCancellation. (2) InvokeFetchHandlerAsync no longer runs the handler for a pre-cancelled token. (3) UnwrapIfPromiseAsync with a pre-cancelled token is cancelled even for a settled promise. (4) A loader's cancellation now fails the import instead of rejecting it.
10 Style #nullable enable in AsyncEntryCancellationTests.cs. The cancellation remarks are now four caller-guidance paragraphs on EvaluateAsync(string, …), inherited with <inheritdoc path="/remarks"/> by the other four entries. The Throw.OperationCanceledException(string, Exception, CancellationToken) helper was not added, because finding 7 removed the only throw site that needed it.
11 Performance New AsyncEntryTokenBenchmark with rows Evaluate (control), EvaluateAsyncWithoutToken and EvaluateAsyncWithLiveToken, each over 3d-cube, access-nbody, bitops-bitwise-and and crypto-md5. Every op builds its own engine. The class uses only APIs that exist at 97e553b28. Filter: --filter "Jint.Benchmark.AsyncEntryTokenBenchmark.*" (12 cases). Not run here.

Contract changes

  • InvokeFetchHandlerAsync with an already-cancelled token is now cancelled before the handler runs. It used to run the handler with an aborted request.signal. This follows from finding 3. The public test that pinned the old behaviour is replaced (TheAwaitableShapeRunsNoHandlerForAnAlreadyCancelledToken). The polled InvokeFetchHandler(request, token) still runs the handler, and the docs point to it.

Tests

Failing first. Every behaviour test below failed against unfixed upstream/main, with only an internal admission counter added there so the finding-4 test compiles:

  • Jint.Tests: AHostCallbackStillRunningUnderTheReservationObservesTheToken (4) and AHostCancellationRaisedWhileAwaitingKeepsItsOwnIdentity (7).
  • Jint.Tests.PublicInterface:
    • ANestedBlockingUnwrapIsWokenByTheCallsToken, ANestedBlockingUnwrapAfterTheCallWasCancelledDoesNotWait, ANestedBlockingImportIsWokenByTheCallsToken and ANestedParkIsWokenByTheCallsToken (2).
    • UnwrapIfPromiseAsyncStopsALoopingContinuation, TheAwaitableShapesTokenStopsAHandlerThatNeverYields and TheAwaitableShapeRunsNoHandlerForAnAlreadyCancelledToken (3).
    • TheCallsTokenReachesTheLoadersFetch, ALoadersCancellationForTheCallsTokenPropagates and TheCallsTokenReachesAFetchThatOutlivesTheCall (1).
    • ACancelThatWakesTheWaitWinsOverATurnThatWouldSettle (5). It is deterministic: the timer is due on a manual clock before the token is cancelled, so the cancel is the only thing that can wake the wait.

Pins. ACancelledCallLeavesItsQueuedJobsForTheNextPump and RestoringASnapshotDiscardsWhatACancelledCallQueued pin the documented behaviour for finding 8.

Release runs on the rebased branch:

Project Framework Scope Total Failed Skipped
Jint.Tests net10.0 full suite 13356 0 5
Jint.Tests net472 touched classes 398 0 0
Jint.Tests net8.0 touched web-API classes 458 0 0
Jint.Tests.PublicInterface net10.0 full suite 3974 0 21
Jint.Tests.PublicInterface net472 full suite 3142 0 21
Jint.Tests.PublicInterface net8.0 web-API and module tests 793 0 0

The full solution builds in Release.

Overlap

🤖 Generated with Claude Code

https://claude.ai/code/session_015Sm5FdCDxoX4BMKdcK2CMY

lahma and others added 2 commits October 10, 2026 10:51
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 sebastienros#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
… *Async call's token

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

lahma commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Paired measurement of the new AsyncEntryTokenBenchmark: #4226's parent 97e553b28 (baseline, with the benchmark class copied in) against this PR's head 8272402c5. I ran 3 interleaved rounds with alternating order on a quiet machine, and no other build or test ran during the run.

Row 3d-cube access-nbody bitops-bitwise-and crypto-md5
Evaluate (control, no token) +1.97% [-0.23, +5.03] +3.07% [+1.90, +3.62] -0.26% [-0.41, +5.88] +1.18% [-0.22, +2.44]
EvaluateAsyncWithoutToken -0.77% [-1.63, +4.30] -0.71% [-2.03, +2.53] -0.15% [-0.21, +4.44] -0.41% [-1.15, +2.04]
EvaluateAsyncWithLiveToken +2.71% [+2.39, +8.98] +3.69% [-0.77, +5.71] +2.94% [+0.83, +3.42] +0.74% [-1.55, +1.70]

The first number in each cell is the median change; the bracketed pair is its 95% CI. Bold rows have a CI that excludes zero.

  • This PR's restructuring of the *Async entries costs nothing measurable when no token is passed. None of the EvaluateAsyncWithoutToken intervals excludes zero, and every median is slightly faster.
  • A cancellable token that is never cancelled costs about 1–4%. That is the live-token rows compared with the no-token rows. It is the price of Make the *Async entries honour their own cancellation token #4226's design rather than of this PR: such a token arms the interpreter's every-64-statements check, which costs the same as registering ObserveCancellation for that token. Before Make the *Async entries honour their own cancellation token #4226, the token did nothing inside the interpreter.
  • The synchronous control drifted between the two commits, by up to +3% (access-nbody). The sync path is untouched by both PRs, so that drift comes from other commits merged in between. It is also why I'm reading the token cost from the async no-token rows rather than from the absolute numbers.

If the live-token cost ever matters, the follow-up would be letting a token's registration callback raise a flag that the per-statement path already checks. That makes a token that is never cancelled free. It's a separate change and not needed for correctness.

@lahma
lahma merged commit a3e20b5 into sebastienros:main Oct 13, 2026
13 checks passed
@lahma
lahma deleted the async-entry-token-followup branch October 13, 2026 18:39
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