Skip to content

Add opt-in timers: setTimeout, setInterval, clearTimeout, clearInterval and queueMicrotask - #3090

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:webapi/timers
Aug 20, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:webapi/timers

Conversation

@lahma

@lahma lahma commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Second PR of the opt-in web API series (#3079): setTimeout, setInterval, clearTimeout, clearInterval and queueMicrotask, behind WebApiFeatures.Timers (now part of Default). 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 (PriorityQueue keyed 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) beats setTimeout(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 and EvaluateAsync'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 calls Advanced.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 long delay 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/clearInterval interchangeable and silent on unknown ids. setTimeout(string) is deliberately a TypeError (it is eval by another name, and it's what Node does). Clock is a TimeProvider (Options.WebApi.Timers.TimeProvider) read via GetTimestamp only — never CreateTimer. Options.WebApi.Timers.MaxActiveTimers (default 1000) bounds registration; exceeding it is a catchable QuotaExceededError DOMException. Pending timers are cleared by RestoreGlobalSnapshot'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 becomes Task.WhenAny(wait, Task.Delay(due)) — one Delay per 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).
  • Engine gains one reference field (+8 bytes per engine on net8+; zero downlevel).
  • No allocation on any no-timer path; a timer firing allocates nothing (the job delegate is created once per timer and reused by intervals).

Measurement

Paired interleaved A/B (measure-paired.ps1 protocol, 8 rounds, alternating order, per-round difference with percentile-bootstrap 95% CI), baseline and candidate differing by exactly this commit:

row median Δ% 95% CI verdict
MinimalScriptBenchmark.Execute +0.49 [−1.01, +1.60] no change
MinimalScriptBenchmark.Execute_ParsedScript −0.53 [−3.39, +3.15] no change
AsyncAwaitBenchmark.AwaitChainDepth50 +0.80 [−1.49, +2.08] no change
AsyncAwaitBenchmark.AwaitResolvedLoop +4.95 [−3.40, +7.96] no change
AsyncAwaitBenchmark.MicrotaskFanout −1.40 [−4.02, +0.60] no change
AsyncAwaitBenchmark.PromiseAll100 −0.57 [−3.09, +0.97] no change
AsyncAwaitBenchmark.SyncCallLoop +4.55 [+0.62, +8.76] see below
AsyncAwaitBenchmark.ThenChain1000 −0.08 [−4.45, +1.80] no change
AsyncFunctionExitBenchmark.AsyncFunctionWithSyncUsing_1000 +1.17 [−1.26, +3.62] no change
AsyncFunctionExitBenchmark.PlainAsyncFunctionExit_1000 +6.54 [−5.29, +15.29] no change
AsyncFunctionExitBenchmark.PlainAsyncGeneratorExit_500 +0.18 [−0.85, +1.67] no change

MinimalScriptBenchmark.Execute is 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-iteration AsyncFunctionExit rows) 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-byte Engine field, 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 under UnwrapIfPromise, EvaluateAsync, and a manual ProcessTasks polling loop), and the public-surface pins (default engine has no timer globals; a host-registered setTimeout wins). 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

…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>
@lahma

lahma commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

CI note: the two Linux legs each failed one test262 case from the async Atomics.waitAsync family (returns-result-object-value-is-promise-resolves-to-timed-out.js on x64, bigint/true-for-timeout.js on ARM), both with "Test timed out" — a different test per leg, while windows/macos ran the same suite green. This family is a known load-sensitive flake that also occurs on unmodified main (these tests race a real 100ms-scale timeout against CI runner scheduling), and the test262 engine enables no web APIs, so every code path this PR changes is behind a null check that engine never passes. Full test262 was green in two local runs of this exact content. Re-triggered CI by amending with a byte-identical tree (verified same tree SHA).

@lahma
lahma merged commit b6977b3 into sebastienros:main Aug 20, 2026
8 of 10 checks passed
@lahma
lahma deleted the webapi/timers branch August 20, 2026 19:42
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