Skip to content

Engine: park a host thread on the pump with Advanced.WaitForScheduledWork - #3217

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:feature/wait-for-scheduled-work
Aug 21, 2026
Merged

lahma merged 2 commits into
sebastienros:mainfrom
lahma:feature/wait-for-scheduled-work

Conversation

@lahma

@lahma lahma commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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.TimeUntilNextScheduledWork answers when to pump, but a job that arrives from another
thread — an interop Task settling, a message posted in from a second engine, an asynchronous module load
completing — 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:

public bool WaitForScheduledWork(TimeSpan timeout, CancellationToken cancellationToken = default);
public Task<bool> WaitForScheduledWorkAsync(TimeSpan timeout, CancellationToken cancellationToken = default);

Contract. Returns true when there is (probably) work — call ProcessTasks() next — and false on
timeout. It does not pump: ProcessTasks stays the pump and there is still no third method that drains
for 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.Enqueue
wakes it immediately through the work-arrived event that already existed for the module-load drain. A
registered CancellationConstraint ends it as ExecutionCanceledException, the caller's token as
OperationCanceledException, 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 is
self-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 Task exists,
exactly as EvaluateAsync reports it — and re-claims the resuming thread per look at the schedule, since
the 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 internal
waits 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 new Engine field, no new synchronization. All
target frameworks, no web-API flag, nothing under Jint/WebApi/. README's host-loop section now shows the
park-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 a
guarded entry is refused for its whole duration) and Jint.Tests.PublicInterface (a cross-thread promise
settle 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

lahma and others added 2 commits August 21, 2026 23:34
…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
lahma force-pushed the feature/wait-for-scheduled-work branch from 9110285 to 215dd36 Compare August 21, 2026 20:44
@lahma
lahma merged commit df2990e into sebastienros:main Aug 21, 2026
9 of 10 checks passed
@lahma
lahma deleted the feature/wait-for-scheduled-work branch August 23, 2026 10:00
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