Skip to main content

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//! A host with ambient per-callback state (a capability grant, a request
15//! scope) supplies it as a [`CallbackPolicy`]: it is captured when the
16//! timer is armed and re-entered when the callback fires, so a callback
17//! registered under a restriction keeps it instead of falling back to
18//! whatever the resting state happens to be. Hosts without such state
19//! install [`NoPolicy`].
20
21use std::sync::Arc;
22use std::time::Duration;
23
24use rquickjs::function::{Func, Rest};
25use rquickjs::{Class, Ctx, Function, JsLifetime, Value, class::Trace};
26use tokio::sync::Notify;
27
28/// Ambient host state that a scheduled callback must run under.
29pub trait CallbackPolicy: Clone + 'static {
30  /// The state in force right now, if any.
31  fn capture(ctx: &Ctx<'_>) -> Option<Self>
32  where
33    Self: Sized;
34
35  /// Run `f` with `policy` in force, restoring the caller's state after.
36  fn enter<R>(ctx: &Ctx<'_>, policy: Option<&Self>, f: impl FnOnce() -> R) -> R
37  where
38    Self: Sized;
39}
40
41/// For hosts with no ambient callback state: callbacks run as they are.
42#[derive(Clone, Copy)]
43pub struct NoPolicy;
44
45impl CallbackPolicy for NoPolicy {
46  fn capture(_ctx: &Ctx<'_>) -> Option<Self> {
47    None
48  }
49
50  fn enter<R>(_ctx: &Ctx<'_>, _policy: Option<&Self>, f: impl FnOnce() -> R) -> R {
51    f()
52  }
53}
54
55/// Opaque timer handle returned by `setTimeout` / `setInterval`.
56#[derive(Trace, JsLifetime)]
57#[rquickjs::class]
58pub struct Timeout {
59  #[qjs(skip_trace)]
60  abort: Arc<Notify>,
61}
62
63/// `clearTimeout(handle?)` / `clearInterval(handle?)`. Node ignores
64/// `undefined`, `null`, numbers, foreign objects — anything that is not
65/// a live timer handle — so the argument is taken as a raw `Value` and
66/// only acted on when it is actually a [`Timeout`].
67fn clear_timeout(value: Rest<Value<'_>>) {
68  if let Some(v) = value.0.first() {
69    if let Ok(timeout) = Class::<Timeout>::from_value(v) {
70      timeout.borrow().abort.notify_one();
71    }
72  }
73}
74
75fn set_timeout_interval<'js, P: CallbackPolicy>(
76  ctx: Ctx<'js>,
77  cb: Function<'js>,
78  msec: Option<f64>,
79  args: Vec<Value<'js>>,
80  is_interval: bool,
81) -> rquickjs::Result<Class<'js, Timeout>> {
82  // 4ms floor, matching the HTML spec's nested-timeout clamp. Node
83  // clamps NaN/negative and >2^31-1 delays to 1ms — treat all of those
84  // as the floor.
85  let msecs = match msec {
86    Some(ms) if ms.is_finite() && ms >= 0.0 && ms < f64::from(i32::MAX) => ms as u64,
87    _ => 0,
88  };
89  let duration = Duration::from_millis(msecs.max(4));
90
91  let abort = Arc::new(Notify::new());
92  let abort_ref = abort.clone();
93  let policy = P::capture(&ctx);
94
95  ctx.spawn(async move {
96    loop {
97      let mut interval = tokio::time::interval(duration);
98      interval.tick().await; // Skip the immediate first tick.
99      let aborted = tokio::select! {
100        () = abort_ref.notified() => true,
101        _ = interval.tick() => false,
102      };
103      if aborted {
104        break;
105      }
106      // Node passes `setTimeout(cb, ms, ...args)` extras through to
107      // every invocation.
108      let mut call_args = rquickjs::function::Args::new(cb.ctx().clone(), args.len());
109      let ok = call_args.push_args(args.iter().cloned()).is_ok();
110      if !ok || {
111        let res: rquickjs::Result<()> = P::enter(cb.ctx(), policy.as_ref(), || cb.call_arg(call_args));
112        res
113          .inspect_err(|err| tracing::warn!(target: "ferrijs::timers", "timer callback threw: {err}"))
114          .is_err()
115      } {
116        break;
117      }
118      if !is_interval {
119        break;
120      }
121    }
122  });
123
124  Class::instance(ctx, Timeout { abort })
125}
126
127fn set_timeout<'js, P: CallbackPolicy>(
128  ctx: Ctx<'js>,
129  cb: Function<'js>,
130  rest: Rest<Value<'js>>,
131) -> rquickjs::Result<Class<'js, Timeout>> {
132  let (msec, args) = split_delay_args(rest.0);
133  set_timeout_interval::<P>(ctx, cb, msec, args, false)
134}
135
136fn set_interval<'js, P: CallbackPolicy>(
137  ctx: Ctx<'js>,
138  cb: Function<'js>,
139  rest: Rest<Value<'js>>,
140) -> rquickjs::Result<Class<'js, Timeout>> {
141  let (msec, args) = split_delay_args(rest.0);
142  set_timeout_interval::<P>(ctx, cb, msec, args, true)
143}
144
145/// Split `(delay?, ...args)` off the rest parameters, coercing the
146/// delay to a number the way JS timers do (`undefined`/non-numeric ⇒ 0).
147fn split_delay_args(mut rest: Vec<Value<'_>>) -> (Option<f64>, Vec<Value<'_>>) {
148  if rest.is_empty() {
149    return (None, rest);
150  }
151  let delay = rest.remove(0);
152  (delay.as_number(), rest)
153}
154
155/// `setImmediate(cb, ...args)` — deferred to the microtask-adjacent job
156/// queue, args passed through like Node. With a captured policy the
157/// callback is wrapped in a native bracket so the deferred job runs
158/// under it (same rule as `setTimeout`).
159fn set_immediate<'js, P: CallbackPolicy>(
160  ctx: Ctx<'js>,
161  cb: Function<'js>,
162  rest: Rest<Value<'js>>,
163) -> rquickjs::Result<()> {
164  match P::capture(&ctx) {
165    None => {
166      let mut args = rquickjs::function::Args::new(ctx, rest.0.len());
167      args.push_args(rest.0)?;
168      cb.defer_arg(args)
169    },
170    Some(policy) => {
171      // The wrapper captures only the policy (plain data); the real
172      // callback rides the deferred args (a native closure must never
173      // capture a JS value or a `Persistent` — untraceable GC cycle at
174      // teardown). A `Rest`-only signature keeps every JS value on one
175      // `'js`.
176      let policy = Some(policy);
177      let wrapper = Function::new(ctx.clone(), move |args: Rest<Value<'_>>| {
178        deferred_call::<P>(policy.as_ref(), &args.0)
179      })?;
180      let mut args = rquickjs::function::Args::new(ctx, rest.0.len() + 1);
181      args.push_arg(cb)?;
182      args.push_args(rest.0)?;
183      wrapper.defer_arg(args)
184    },
185  }
186}
187
188/// Call the deferred callback (args[0]) with the rest of the args, under
189/// `policy`.
190fn deferred_call<P: CallbackPolicy>(policy: Option<&P>, args: &[Value<'_>]) -> rquickjs::Result<()> {
191  let inner = args.first().and_then(|v| v.as_function().cloned()).ok_or_else(|| {
192    rquickjs::Error::new_from_js_message("setImmediate", "Error", "deferred callback missing".to_string())
193  })?;
194  let ctx = inner.ctx().clone();
195  let mut call_args = rquickjs::function::Args::new(ctx.clone(), args.len().saturating_sub(1));
196  call_args.push_args(args.iter().skip(1).cloned())?;
197  P::enter(&ctx, policy, || inner.call_arg(call_args))
198}
199
200/// WHATWG `queueMicrotask(cb)`. A named generic fn so `Ctx`, the
201/// callback, and the wrapper share one `'js` (an inline closure would
202/// give each its own lifetime).
203fn queue_microtask<'js, P: CallbackPolicy>(ctx: Ctx<'js>, cb: Function<'js>) -> rquickjs::Result<()> {
204  match P::capture(&ctx) {
205    None => cb.defer::<()>(()),
206    Some(policy) => {
207      let policy = Some(policy);
208      let wrapper = Function::new(ctx.clone(), move |args: Rest<Value<'_>>| {
209        deferred_call::<P>(policy.as_ref(), &args.0)
210      })?;
211      wrapper.defer((cb,))
212    },
213  }
214}
215
216/// Install the timer globals, carrying `P` from registration to callback.
217///
218/// # Errors
219///
220/// Propagates the global writes.
221pub fn install<P: CallbackPolicy>(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
222  let globals = ctx.globals();
223  globals.set("setTimeout", Func::from(set_timeout::<P>))?;
224  globals.set("clearTimeout", Func::from(clear_timeout))?;
225  globals.set("setInterval", Func::from(set_interval::<P>))?;
226  globals.set("clearInterval", Func::from(clear_timeout))?;
227  globals.set("setImmediate", Func::from(set_immediate::<P>))?;
228  // The job queue drains outside whatever bracket the registrar ran in,
229  // so a microtask it queued must carry the policy with it (same rule as
230  // `setTimeout` / `setImmediate`).
231  globals.set("queueMicrotask", Func::from(queue_microtask::<P>))?;
232  Ok(())
233}