ferrijs_std/web/timers.rs
1//! `setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` /
2//! `setImmediate` / `queueMicrotask` — native, `ctx.spawn`-backed (the
3//! timer future lives on the host's VM executor, so callbacks fire
4//! between executes and while a script is parked on a host await;
5//! dropping the runtime aborts every armed timer).
6//!
7//! The timer handle is a [`Timeout`] class instance (not a numeric id):
8//! it survives REPL-style across evaluations via `globalThis` and
9//! `clearTimeout(handle)` cancels through its `Notify`. Holding the JS
10//! callback inside the spawned future is the sanctioned
11//! executor-owned-future shape (same as `AbortSignal.timeout`) — the
12//! future is dropped with the runtime, never stored in a traced JS field.
13//!
14//! The delay follows the HTML spec rather than Node. A `setTimeout(fn,
15//! 0)` runs on the next turn of the event loop, after the microtask
16//! checkpoint, instead of waiting out Node's unconditional one
17//! millisecond -- for a script that polls with `await sleep(0)` that is
18//! the difference between microseconds and milliseconds per turn. What
19//! keeps that from starving the loop is the spec's own guard, the timer
20//! NESTING LEVEL: a timeout armed from inside a timer callback is one
21//! level deeper than the callback's own, and past level five a delay
22//! under 4ms is raised to 4ms. `setInterval` deepens a level per
23//! repeat, so a zero-delay interval free-runs a few times and then
24//! settles at 4ms, which is what a browser does.
25//!
26//! A host with ambient per-callback state (a capability grant, a request
27//! scope) supplies it as a [`CallbackPolicy`]: it is captured when the
28//! timer is armed and re-entered when the callback fires, so a callback
29//! registered under a restriction keeps it instead of falling back to
30//! whatever the resting state happens to be. Hosts without such state
31//! install [`NoPolicy`].
32
33use std::sync::Arc;
34use std::sync::atomic::{AtomicU32, Ordering};
35use std::time::Duration;
36
37use rquickjs::function::{Func, Rest};
38use rquickjs::{Class, Ctx, Function, JsLifetime, Value, class::Trace};
39use tokio::sync::Notify;
40
41/// Past this nesting depth the HTML spec raises a sub-4ms delay to 4ms.
42/// It is what stops `setTimeout(f, 0)` recursion from spinning the loop
43/// now that the first level really does fire on the next turn.
44const MAX_FREE_NESTING: u32 = 5;
45
46/// The HTML timer nesting level currently in force: zero outside any
47/// timer callback, and the firing timer's own level inside one.
48///
49/// Kept as realm userdata rather than captured by the arming closures,
50/// because `setTimeout` has to be a named generic function: an inline
51/// closure gives `Ctx`, the callback and the returned handle three
52/// separate `'js` lifetimes, and the handle is invariant over its own.
53#[derive(Clone)]
54struct Nesting(Arc<AtomicU32>);
55
56// SAFETY: owns only an `Arc<AtomicU32>`; no borrowed JS values, so
57// restating the unused `'js` lifetime is sound.
58#[allow(unsafe_code)]
59unsafe impl JsLifetime<'_> for Nesting {
60 type Changed<'to> = Nesting;
61}
62
63/// The realm's nesting counter, or a detached one for a realm whose
64/// host installed timers without it (the level then never deepens,
65/// which is the pre-existing behaviour rather than a new hazard).
66fn nesting_of(ctx: &Ctx<'_>) -> Arc<AtomicU32> {
67 ctx
68 .userdata::<Nesting>()
69 .map_or_else(|| Arc::new(AtomicU32::new(0)), |n| Arc::clone(&n.0))
70}
71
72/// Ambient host state that a scheduled callback must run under.
73pub trait CallbackPolicy: Clone + 'static {
74 /// The state in force right now, if any.
75 fn capture(ctx: &Ctx<'_>) -> Option<Self>
76 where
77 Self: Sized;
78
79 /// Run `f` with `policy` in force, restoring the caller's state after.
80 fn enter<R>(ctx: &Ctx<'_>, policy: Option<&Self>, f: impl FnOnce() -> R) -> R
81 where
82 Self: Sized;
83}
84
85/// For hosts with no ambient callback state: callbacks run as they are.
86#[derive(Clone, Copy)]
87pub struct NoPolicy;
88
89impl CallbackPolicy for NoPolicy {
90 fn capture(_ctx: &Ctx<'_>) -> Option<Self> {
91 None
92 }
93
94 fn enter<R>(_ctx: &Ctx<'_>, _policy: Option<&Self>, f: impl FnOnce() -> R) -> R {
95 f()
96 }
97}
98
99/// Opaque timer handle returned by `setTimeout` / `setInterval`.
100#[derive(Trace, JsLifetime)]
101#[rquickjs::class]
102pub struct Timeout {
103 #[qjs(skip_trace)]
104 abort: Arc<Notify>,
105}
106
107/// `clearTimeout(handle?)` / `clearInterval(handle?)`. Node ignores
108/// `undefined`, `null`, numbers, foreign objects — anything that is not
109/// a live timer handle — so the argument is taken as a raw `Value` and
110/// only acted on when it is actually a [`Timeout`].
111fn clear_timeout(value: Rest<Value<'_>>) {
112 if let Some(v) = value.0.first() {
113 if let Ok(timeout) = Class::<Timeout>::from_value(v) {
114 timeout.borrow().abort.notify_one();
115 }
116 }
117}
118
119/// The delay a script asked for, in whole milliseconds. A negative,
120/// NaN or out-of-range value is zero, which the spec treats as "as soon
121/// as the loop gets to it".
122fn requested_ms(msec: Option<f64>) -> u64 {
123 match msec {
124 Some(ms) if ms.is_finite() && ms >= 1.0 && ms < f64::from(i32::MAX) => ms as u64,
125 _ => 0,
126 }
127}
128
129/// The spec's clamp: past [`MAX_FREE_NESTING`], anything under 4ms
130/// becomes 4ms.
131fn clamped(requested: u64, level: u32) -> Duration {
132 if level > MAX_FREE_NESTING && requested < 4 {
133 Duration::from_millis(4)
134 } else {
135 Duration::from_millis(requested)
136 }
137}
138
139/// Wait out `delay`. A zero delay is not a timer at all: yielding hands
140/// the loop back so the microtask checkpoint runs first (a `setTimeout`
141/// is a task, and a task never precedes a promise continuation already
142/// queued), and the callback fires on the next pass. Going through
143/// tokio's wheel instead would cost the millisecond this whole change
144/// exists to remove.
145async fn wait(delay: Duration) {
146 if delay.is_zero() {
147 tokio::task::yield_now().await;
148 } else {
149 tokio::time::sleep(delay).await;
150 }
151}
152
153fn set_timeout_interval<'js, P: CallbackPolicy>(
154 ctx: Ctx<'js>,
155 cb: Function<'js>,
156 msec: Option<f64>,
157 args: Vec<Value<'js>>,
158 is_interval: bool,
159) -> rquickjs::Result<Class<'js, Timeout>> {
160 let requested = requested_ms(msec);
161 let nesting = nesting_of(&ctx);
162 // A timer armed inside a callback is one level below it.
163 let level = nesting.load(Ordering::Relaxed).saturating_add(1);
164
165 let abort = Arc::new(Notify::new());
166 let abort_ref = abort.clone();
167 let policy = P::capture(&ctx);
168
169 ctx.spawn(async move {
170 // Node passes `setTimeout(cb, ms, ...args)` extras through to every
171 // invocation. Answers whether the timer should keep running. The
172 // nesting level is published for the duration of the call, so a
173 // timer the callback arms sees itself as one level deeper, and is
174 // restored afterwards even when the callback throws.
175 let fire = |level: u32| {
176 let mut call_args = rquickjs::function::Args::new(cb.ctx().clone(), args.len());
177 if call_args.push_args(args.iter().cloned()).is_err() {
178 return false;
179 }
180 let outer = nesting.swap(level, Ordering::Relaxed);
181 let res: rquickjs::Result<()> = P::enter(cb.ctx(), policy.as_ref(), || cb.call_arg(call_args));
182 nesting.store(outer, Ordering::Relaxed);
183 res
184 .inspect_err(|err| tracing::warn!(target: "ferrijs::timers", "timer callback threw: {err}"))
185 .is_ok()
186 };
187
188 if !is_interval {
189 tokio::select! {
190 () = abort_ref.notified() => {},
191 () = wait(clamped(requested, level)) => { fire(level); },
192 }
193 return;
194 }
195
196 // An interval deepens a level per repeat, so its delay is recomputed
197 // each time round rather than fixed at arm time. The deadline is
198 // carried forward instead of restarted after the callback, so the
199 // period does not drift by however long the callback took; a
200 // callback that overruns its own period skips the ticks it missed
201 // rather than firing them back to back.
202 let mut level = level;
203 let mut next = tokio::time::Instant::now() + clamped(requested, level);
204 loop {
205 let delay = next.saturating_duration_since(tokio::time::Instant::now());
206 let aborted = tokio::select! {
207 () = abort_ref.notified() => true,
208 () = wait(delay) => false,
209 };
210 if aborted || !fire(level) {
211 break;
212 }
213 level = level.saturating_add(1);
214 let period = clamped(requested, level);
215 next += period;
216 let now = tokio::time::Instant::now();
217 if next <= now {
218 next = now + period;
219 }
220 }
221 });
222
223 Class::instance(ctx, Timeout { abort })
224}
225
226fn set_timeout<'js, P: CallbackPolicy>(
227 ctx: Ctx<'js>,
228 cb: Function<'js>,
229 rest: Rest<Value<'js>>,
230) -> rquickjs::Result<Class<'js, Timeout>> {
231 let (msec, args) = split_delay_args(rest.0);
232 set_timeout_interval::<P>(ctx, cb, msec, args, false)
233}
234
235fn set_interval<'js, P: CallbackPolicy>(
236 ctx: Ctx<'js>,
237 cb: Function<'js>,
238 rest: Rest<Value<'js>>,
239) -> rquickjs::Result<Class<'js, Timeout>> {
240 let (msec, args) = split_delay_args(rest.0);
241 set_timeout_interval::<P>(ctx, cb, msec, args, true)
242}
243
244/// Split `(delay?, ...args)` off the rest parameters, coercing the
245/// delay to a number the way JS timers do (`undefined`/non-numeric ⇒ 0).
246fn split_delay_args(mut rest: Vec<Value<'_>>) -> (Option<f64>, Vec<Value<'_>>) {
247 if rest.is_empty() {
248 return (None, rest);
249 }
250 let delay = rest.remove(0);
251 (delay.as_number(), rest)
252}
253
254/// `setImmediate(cb, ...args)` — deferred to the microtask-adjacent job
255/// queue, args passed through like Node. With a captured policy the
256/// callback is wrapped in a native bracket so the deferred job runs
257/// under it (same rule as `setTimeout`).
258fn set_immediate<'js, P: CallbackPolicy>(
259 ctx: Ctx<'js>,
260 cb: Function<'js>,
261 rest: Rest<Value<'js>>,
262) -> rquickjs::Result<()> {
263 match P::capture(&ctx) {
264 None => {
265 let mut args = rquickjs::function::Args::new(ctx, rest.0.len());
266 args.push_args(rest.0)?;
267 cb.defer_arg(args)
268 },
269 Some(policy) => {
270 // The wrapper captures only the policy (plain data); the real
271 // callback rides the deferred args (a native closure must never
272 // capture a JS value or a `Persistent` — untraceable GC cycle at
273 // teardown). A `Rest`-only signature keeps every JS value on one
274 // `'js`.
275 let policy = Some(policy);
276 let wrapper = Function::new(ctx.clone(), move |args: Rest<Value<'_>>| {
277 deferred_call::<P>(policy.as_ref(), &args.0)
278 })?;
279 let mut args = rquickjs::function::Args::new(ctx, rest.0.len() + 1);
280 args.push_arg(cb)?;
281 args.push_args(rest.0)?;
282 wrapper.defer_arg(args)
283 },
284 }
285}
286
287/// Call the deferred callback (args[0]) with the rest of the args, under
288/// `policy`.
289fn deferred_call<P: CallbackPolicy>(policy: Option<&P>, args: &[Value<'_>]) -> rquickjs::Result<()> {
290 let inner = args.first().and_then(|v| v.as_function().cloned()).ok_or_else(|| {
291 rquickjs::Error::new_from_js_message("setImmediate", "Error", "deferred callback missing".to_string())
292 })?;
293 let ctx = inner.ctx().clone();
294 let mut call_args = rquickjs::function::Args::new(ctx.clone(), args.len().saturating_sub(1));
295 call_args.push_args(args.iter().skip(1).cloned())?;
296 P::enter(&ctx, policy, || inner.call_arg(call_args))
297}
298
299/// WHATWG `queueMicrotask(cb)`. A named generic fn so `Ctx`, the
300/// callback, and the wrapper share one `'js` (an inline closure would
301/// give each its own lifetime).
302fn queue_microtask<'js, P: CallbackPolicy>(ctx: Ctx<'js>, cb: Function<'js>) -> rquickjs::Result<()> {
303 match P::capture(&ctx) {
304 None => cb.defer::<()>(()),
305 Some(policy) => {
306 let policy = Some(policy);
307 let wrapper = Function::new(ctx.clone(), move |args: Rest<Value<'_>>| {
308 deferred_call::<P>(policy.as_ref(), &args.0)
309 })?;
310 wrapper.defer((cb,))
311 },
312 }
313}
314
315/// Install the timer globals, carrying `P` from registration to callback.
316///
317/// # Errors
318///
319/// Propagates the global writes.
320pub fn install<P: CallbackPolicy>(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
321 let globals = ctx.globals();
322 // One nesting counter per realm, shared by both arming functions:
323 // a `setInterval` armed inside a `setTimeout` callback is nested too.
324 let _ = ctx.store_userdata(Nesting(Arc::new(AtomicU32::new(0))));
325 globals.set("setTimeout", Func::from(set_timeout::<P>))?;
326 globals.set("clearTimeout", Func::from(clear_timeout))?;
327 globals.set("setInterval", Func::from(set_interval::<P>))?;
328 globals.set("clearInterval", Func::from(clear_timeout))?;
329 globals.set("setImmediate", Func::from(set_immediate::<P>))?;
330 // The job queue drains outside whatever bracket the registrar ran in,
331 // so a microtask it queued must carry the policy with it (same rule as
332 // `setTimeout` / `setImmediate`).
333 globals.set("queueMicrotask", Func::from(queue_microtask::<P>))?;
334 Ok(())
335}