Repository navigation
Engine: park a host thread on the pump with Advanced.WaitForScheduledWork - #3217
Merged
Merged
Conversation
lahma
force-pushed
the
feature/wait-for-scheduled-work
branch
from
August 21, 2026 20:08
6929739 to
9110285
Compare
…Work
A host driving one engine per thread had `TimeUntilNextScheduledWork` to tell it
*when* to pump and `ProcessTasks()` to do the pumping, and nothing at all for the
gap between them: a job enqueued from another thread — an interop `Task` settling,
an asynchronous module load completing, a message posted in from a second engine —
carries no due time, so the property cannot report it and the only way to find one
was to poll. A loop sleeping on a 20 ms ceiling therefore paid up to 20 ms of
latency on every such arrival, which for a worker-shaped host is the entire traffic.
public bool WaitForScheduledWork(TimeSpan timeout, CancellationToken cancellationToken = default);
public Task<bool> WaitForScheduledWorkAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
The internals were already complete and are only assembled here: `EventLoop`'s
work-arrived event and its reset-then-check `WaitForWork`, `WaitForEventAsync` for
the asynchronous form, and `TimeUntilNextPumpScheduledWork` for the clamp — so a
`setTimeout(f, 1)` wakes the wait in about a millisecond rather than at the caller's
ceiling. It deliberately **does not pump**: `ProcessTasks()` stays the pump and there
is still no third method that drains for a budget.
Four decisions worth stating:
* **The synchronous form claims the engine for its whole duration.** That is the
sebastienros#3035 admission rule and nothing this method adds, and it is what makes one
drainer per engine self-enforcing — a second thread asking for the wait, or for
any other guarded entry, gets *"This Engine is already in use by another thread"*.
`EventLoop.Enqueue` is deliberately unguarded, so waking a parked engine from
another thread is unaffected. Documented, including the consequence: a host
callback arriving from elsewhere is refused while the wait is parked, where a
`Thread.Sleep` would have admitted it.
* **The asynchronous form cannot hold thread-affine ownership across an await**, so
it takes the reservation every other async host entry takes — synchronously, so an
engine already in use is refused before a `Task` exists — and re-claims the
resuming thread with `EnterTransferredHostCall` for each look at the schedule.
It does not mirror `AwaitPromiseSettlementAsync`'s `_hostCallbackAdmission`
bracket (that exists to keep a converted callback admissible while script is
suspended, and this wait runs no script) nor touch `_pendingAsyncOperations`.
* **Inside a running job nothing counts as available** — the rule `WaitForWork` and
`DrainEventLoopUntil` already apply, for their reason: the re-entrancy guard makes
the queue unrunnable from there, so a `true` would hand back a verdict whose
`ProcessTasks` does nothing and spin the caller's loop hot for its whole ceiling.
* The ceiling is measured on `Stopwatch`, not the wall clock, so an NTP step cannot
cut it short or stretch it — and as elapsed-since-start rather than an absolute
deadline, so a `TimeSpan.MaxValue` ceiling cannot overflow the arithmetic.
`HasImmediatePumpWork` is extracted so that the wait and
`TimeUntilNextScheduledWork` answer "is there work right now" through one helper and
can never disagree.
Core engine surface: every target framework, no `#if`, no web-API flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd
The "Driving the engine from your own loop" section ended by telling a host to keep a cadence of its own for work that arrives from a background thread, which was the honest advice while polling was the only option and is now the wrong one. Replace it with the wait, a pump-loop sample, and the four properties a host has to know before using it: it does not pump, it clamps to the engine's own next due time, a `true` is a hint, and it claims the engine for its whole duration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd
lahma
force-pushed
the
feature/wait-for-scheduled-work
branch
from
August 21, 2026 20:44
9110285 to
215dd36
Compare
This was referenced Aug 21, 2026
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.
PR 0 of the Web Workers plan (#3167 §3 and §14), and useful entirely on its own: the piece a host driving
one engine per thread was missing.
Engine.Advanced.TimeUntilNextScheduledWorkanswers when to pump, but a job that arrives from anotherthread — an interop
Tasksettling, a message posted in from a second engine, an asynchronous module loadcompleting — has no due time for it to report, so a dedicated pump thread could only find such work by
polling, paying up to its poll ceiling in latency on every arrival. This adds the wait that parks on it:
Contract. Returns
truewhen there is (probably) work — callProcessTasks()next — andfalseontimeout. It does not pump:
ProcessTasksstays the pump and there is still no third method that drainsfor a budget. The wait is bounded internally by the engine's own next due time, so a
setTimeout(f, 1)wakes it in about a millisecond rather than at the caller's ceiling; a cross-thread
EventLoop.Enqueuewakes it immediately through the work-arrived event that already existed for the module-load drain. A
registered
CancellationConstraintends it asExecutionCanceledException, the caller's token asOperationCanceledException, and spurious wakes are expected and documented.Ownership is the interesting part, and both forms follow existing discipline rather than inventing any.
The synchronous form holds
EnterHostCall()for its whole duration, so one drainer per engine isself-enforcing — a second thread gets the engine's ordinary "already in use by another thread" admission
error, and enqueueing (deliberately unguarded) still works and is what wakes it. The asynchronous form takes
the async host-operation reservation synchronously — an engine in use is refused before a
Taskexists,exactly as
EvaluateAsyncreports it — and re-claims the resuming thread per look at the schedule, sincethe timer and atomics heaps it consults are engine-thread state. Entered from inside a job, the sync form
idles out and answers
false(the queue is unrunnable from there — the same rule the engine's internalwaits apply), and the async form is refused by its own reservation.
The implementation is a thin assembly over internals that all existed (
EventLoop.WaitForWork/WaitForEventAsync/TimeUntilNextPumpScheduledWork): no newEnginefield, no new synchronization. Alltarget frameworks, no web-API flag, nothing under
Jint/WebApi/. README's host-loop section now shows thepark-and-pump shape instead of telling hosts to keep a cadence of their own.
Tests: 12 across
Jint.Tests(mechanism, including the ownership pin that parks a wait and proves aguarded entry is refused for its whole duration) and
Jint.Tests.PublicInterface(a cross-thread promisesettle waking a parked host, on both TFMs including net472). Every pin was verified to fail against the
mutation it names — the queue-check, the schedule clamp, the sync ownership, the async reservation and
release, the token mapping, and
EventLoop.Enqueue's wake signal.Part of #3167.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd