Repository navigation
Add opt-in timers: setTimeout, setInterval, clearTimeout, clearInterval and queueMicrotask - #3090
Merged
Merged
Conversation
…al and queueMicrotask
The second web-API feature, and the only one that touches engine
infrastructure. Nothing is installed unless a host names
WebApiFeatures.Timers, and every line of it is #if NET8_0_OR_GREATER.
Threading contract, which is the whole design: JavaScript runs only
inside the engine's pump and no thread is ever started to pump it. A
timer fires on the first drain at or after its due time — the end of an
Execute/Evaluate, a blocking UnwrapIfPromise, an awaited EvaluateAsync,
or the host's own Advanced.ProcessTasks() loop — and an engine nobody
pumps never fires one. TimeProvider.GetTimestamp/GetElapsedTime is the
clock; TimeProvider.CreateTimer deliberately never appears.
- TimerQueue: a min-heap of due times keyed by (timestamp, sequence) so
timers due at the same instant fire in registration order, plus an id
map for O(1) clearing. Engine-thread only and lock-free; a cancelled
entry is marked and discarded lazily when it surfaces.
- The single job queue IS the microtask queue: RunAvailableContinuations
promotes exactly one due timer, and only once TryDequeue has failed.
So Promise.resolve().then(f) beats setTimeout(g, 0), every timer gets
its own microtask checkpoint, and no interval can starve reactions.
The per-job path is unchanged; the check costs one null test per queue
exhaustion on an engine with timers enabled and nothing at all on any
other target framework.
- A timer coming due enqueues nothing and so wakes nobody, which is why
both idle waits now bound themselves by the next due time:
DrainEventLoopUntil clamps its poll interval, and
AwaitPromiseSettlementAsync takes a new EventLoop.WaitForEventAsync
overload — one Task.Delay per idle wait, and only while a timer pends.
Neither skips its wait when nested inside a running job, where the
re-entrancy guard makes the queue unrunnable and an early return would
spin hot for the caller's whole timeout.
- ResetTransientEvaluationState clears the queue, so a timer registered
before a RestoreGlobalSnapshot can never fire into the globals it put
back; each entry also carries its registration-time event-loop
generation into the job it is promoted as.
- Spec fidelity: extra arguments forwarded on every firing, WebIDL long
coercion of the delay (NaN and negatives become 0), the nesting-level
clamp to 4ms beyond five levels — which is also what stops a
setTimeout(f, 0) chain spinning the pump — and an interval re-armed
before its callback runs, so a throwing callback cannot silently stop
it. setTimeout("code") is a TypeError: the string form is eval by
another name, and Node refuses it too.
- MaxActiveTimers (1000) bounds how many timers a script may register at
once and turns the excess into a catchable QuotaExceededError
DOMException. Both it and the TimeProvider are read once, when the
engine is built, so two engines sharing one Options instance keep
independent queues.
- A callback that throws erupts from whatever was pumping, exactly as a
promise reaction without a capability does, and the rest of the queue
runs on the next pump.
- Jint.Repl enables the feature, README gains a section on the pump
contract, and AGENTS.md documents the four rules holding the event
loop together.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collaborator
Author
|
CI note: the two Linux legs each failed one test262 case from the async |
This was referenced Aug 20, 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.
Second PR of the opt-in web API series (#3079):
setTimeout,setInterval,clearTimeout,clearIntervalandqueueMicrotask, behindWebApiFeatures.Timers(now part ofDefault). This is the only PR in the series that touches the engine's hot paths, so it comes with a paired benchmark run — details below.The design in one paragraph
Timers live in an engine-thread-only min-heap (
PriorityQueuekeyed on due-timestamp + registration sequence, so equal due times fire FIFO), and no background thread ever exists: a due timer is promoted into the event-loop queue only when the pump finds that queue empty, which is exactly what makes the single job queue behave as the microtask queue —Promise.resolve().then(f)beatssetTimeout(g, 0), every reaction a job queues runs before the next timer is even considered, and a due interval can never starve promise reactions. The blocking drain andEvaluateAsync's idle wait learn "wake at the next due time" (the pre-existing 10 ms poll already made ≥10 ms timers correct; the clamp buys sub-10 ms latency), and a host that only ever callsAdvanced.ProcessTasks()gets timers on the next pump at/after their due time. If nobody pumps, nothing fires — documented on the option.Spec fidelity per https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html: extra arguments forwarded to the callback, WebIDL
longdelay coercion (NaN/negative → 0, 2³¹ wrap), the >5-deep <4 ms → 4 ms nesting clamp (also what stops a zero-delay chain spinning the pump hot), intervals re-armed before the callback so a throw doesn't kill them,clearTimeout/clearIntervalinterchangeable and silent on unknown ids.setTimeout(string)is deliberately aTypeError(it isevalby another name, and it's what Node does). Clock is aTimeProvider(Options.WebApi.Timers.TimeProvider) read viaGetTimestamponly — neverCreateTimer.Options.WebApi.Timers.MaxActiveTimers(default 1000) bounds registration; exceeding it is a catchableQuotaExceededErrorDOMException. Pending timers are cleared byRestoreGlobalSnapshot's transient-state reset, with the registration-time event-loop generation carried on each entry as the second fence.Hot-path footprint (the complete list)
EventLoop.RunAvailableContinuations: the per-job path (dequeue → generation check → run) is byte-identical; only the loop-exit branch moved. At queue exhaustion — once per drain, not per job — one field load + null test + branch on net8+; the block does not exist on net462/netstandard.Engine.DrainEventLoopUntil/AwaitPromiseSettlementAsync: one_webApi?.TimeUntilNextDueTimer()per idle-wait iteration (never during script execution); when a timer pends, the async wait becomesTask.WhenAny(wait, Task.Delay(due))— oneDelayper idle wait, zero when no timer exists. Both due-now fast paths are guarded by!IsRunningJob(nested inside a job this thread cannot run the queue, so an early return would spin hot).Enginegains one reference field (+8 bytes per engine on net8+; zero downlevel).Measurement
Paired interleaved A/B (
measure-paired.ps1protocol, 8 rounds, alternating order, per-round difference with percentile-bootstrap 95% CI), baseline and candidate differing by exactly this commit:MinimalScriptBenchmark.Executeis the maximally sensitive row — the drain is essentially the whole measurement — and it is flat. The one flagged row,SyncCallLoop, we judged noise: it is a plain synchronous-call loop whose body executes none of the changed code; the sibling rows whose bulk is the same synchronous work (the three 1000-iterationAsyncFunctionExitrows) read no-change; its allocations are byte-identical; and one borderline flag among eleven rows at 95% is the expected false-positive rate. Allocation parity across all 8 rounds is byte-exact except the declared +8-byteEnginefield, visible only on rows that construct an engine per op. The broad SunSpider/Dromaeo suites are deferred to the pre-release regression pass by maintainer decision — one branch per drain amortized over seconds-long scripts cannot reach them, and the raw paired data is archived if it's ever wanted.Tests
45 new tests across both suites: microtask-before-timer ordering, per-timer microtask checkpoints, same-due FIFO, extra-args, the nesting clamp under a fake
TimeProvider, interval-survives-throw, quota, restore-fence (a pre-restore timer never fires and the in-flight HTTP-free equivalent is proven), the blocking-drain case (await new Promise(r => setTimeout(r, 100))completes underUnwrapIfPromise,EvaluateAsync, and a manualProcessTaskspolling loop), and the public-surface pins (default engine has no timer globals; a host-registeredsetTimeoutwins). Two design decisions are pinned by mutation: promoting all due timers at once, or re-arming an interval after its callback, each makes a specific test fail. Full test262 green.🤖 Generated with Claude Code