Skip to main content

ferrijs_std/web/
performance.rs

1//! `performance`: High Resolution Time, User Timing and the Performance
2//! Timeline.
3//!
4//! `now()` reads a monotonic [`Instant`], never the wall clock. That is
5//! the whole point of the API — a `Date.now()` delta can go backwards
6//! when NTP steps the clock mid-measurement, and a timing number that
7//! silently goes backwards is worse than no timing number. The wall
8//! clock appears exactly once, as `timeOrigin`, which is what the
9//! monotonic readings are relative to.
10//!
11//! [`monotonic_base`] is also what `process.hrtime` counts from, so the
12//! two clocks are correlatable the way Node's are (both derive from one
13//! libuv hrtime there).
14//!
15//! Covered: `now`, `timeOrigin`, `toJSON`, `mark`, `measure`,
16//! `clearMarks`, `clearMeasures`, `getEntries`, `getEntriesByName`,
17//! `getEntriesByType`, and the `PerformanceEntry` / `PerformanceMark` /
18//! `PerformanceMeasure` classes with `PerformanceEntry` as their
19//! prototype, so `mark instanceof PerformanceEntry` holds.
20//!
21//! Not covered: `PerformanceObserver` (it needs a task-queue hook this
22//! runtime has no equivalent of), the resource/navigation timing entry
23//! types (no document), and Node's `eventLoopUtilization` / `nodeTiming`.
24//! A buffer size limit is not implemented either; nothing here evicts,
25//! so a program marking in a hot loop grows the buffer until it calls
26//! `clearMarks`, exactly as the spec's `maxBufferSize` exists to bound.
27
28use std::time::Instant;
29
30use rquickjs::class::Trace;
31use rquickjs::function::Opt;
32use rquickjs::{Class, Ctx, Exception, Object, Value};
33
34/// Monotonic base for `performance.now()`, and the wall-clock instant it
35/// corresponds to (`performance.timeOrigin`). Both are fixed at first
36/// use, which is process start for any real session.
37static PROCESS_START: std::sync::LazyLock<Instant> = std::sync::LazyLock::new(Instant::now);
38static TIME_ORIGIN: std::sync::LazyLock<f64> = std::sync::LazyLock::new(|| {
39  // Touch the monotonic base first so the two are taken together.
40  let _ = *PROCESS_START;
41  std::time::SystemTime::now()
42    .duration_since(std::time::UNIX_EPOCH)
43    .map_or(0.0, |d| d.as_secs_f64() * 1000.0)
44});
45
46/// The process-wide monotonic origin. `process.hrtime` counts from this
47/// too, so a script can line its readings up against `performance.now()`.
48#[must_use]
49pub fn monotonic_base() -> Instant {
50  *PROCESS_START
51}
52
53/// `performance.now()` in fractional milliseconds.
54#[must_use]
55pub fn now_ms() -> f64 {
56  PROCESS_START.elapsed().as_secs_f64() * 1000.0
57}
58
59/// `performance.timeOrigin`: Unix-epoch milliseconds at the monotonic
60/// base.
61#[must_use]
62pub fn time_origin_ms() -> f64 {
63  *TIME_ORIGIN
64}
65
66const MARK: &str = "mark";
67const MEASURE: &str = "measure";
68
69/// Read `PerformanceMarkOptions` into `(startTime, detail)`.
70///
71/// Shared by `performance.mark()` and `new PerformanceMark()` so the two
72/// cannot disagree about a default or about which values are refused.
73fn mark_options<'js>(ctx: &Ctx<'js>, options: Option<&Value<'js>>) -> rquickjs::Result<(f64, Value<'js>)> {
74  let mut start_time = now_ms();
75  let mut detail = Value::new_null(ctx.clone());
76  let Some(options) = options.and_then(rquickjs::Value::as_object) else {
77    return Ok((start_time, detail));
78  };
79  if let Some(given) = options
80    .get::<_, Value<'js>>("startTime")
81    .ok()
82    .filter(|v| !v.is_undefined())
83  {
84    let Some(n) = given.as_number() else {
85      return Err(Exception::throw_type(ctx, "startTime must be a number"));
86    };
87    if n < 0.0 {
88      return Err(Exception::throw_type(ctx, "startTime cannot be negative"));
89    }
90    start_time = n;
91  }
92  if let Some(given) = options.get::<_, Value<'js>>("detail").ok().filter(|v| !v.is_undefined()) {
93    detail = given;
94  }
95  Ok((start_time, detail))
96}
97
98/// `PerformanceEntry` — the base every timeline entry reads as.
99///
100/// Marks and measures are their own classes so `entryType` cannot
101/// disagree with the constructor, and both chain their prototype here.
102#[derive(Trace, Clone)]
103#[rquickjs::class(rename = "PerformanceEntry")]
104pub struct PerformanceEntryJs {
105  #[qjs(skip_trace)]
106  name: String,
107  #[qjs(skip_trace)]
108  entry_type: String,
109  #[qjs(skip_trace)]
110  start_time: f64,
111  #[qjs(skip_trace)]
112  duration: f64,
113}
114
115#[allow(unsafe_code)]
116unsafe impl rquickjs::JsLifetime<'_> for PerformanceEntryJs {
117  type Changed<'to> = PerformanceEntryJs;
118}
119
120#[rquickjs::methods(rename_all = "camelCase")]
121impl PerformanceEntryJs {
122  /// Exposed as a global but not constructible: its IDL declares no
123  /// constructor, and the global has to exist anyway for
124  /// `mark instanceof PerformanceEntry` to be answerable. rquickjs only
125  /// puts a class on `globalThis` when it HAS a constructor, so the
126  /// throwing one is what makes the name reachable.
127  #[qjs(constructor)]
128  fn constructor(ctx: Ctx<'_>) -> rquickjs::Result<Self> {
129    Err(Exception::throw_type(&ctx, "Illegal constructor"))
130  }
131
132  #[qjs(get)]
133  fn name(&self) -> String {
134    self.name.clone()
135  }
136
137  #[qjs(get)]
138  fn entry_type(&self) -> String {
139    self.entry_type.clone()
140  }
141
142  #[qjs(get)]
143  fn start_time(&self) -> f64 {
144    self.start_time
145  }
146
147  #[qjs(get)]
148  fn duration(&self) -> f64 {
149    self.duration
150  }
151
152  #[qjs(rename = "toJSON")]
153  fn to_json<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
154    let o = Object::new(ctx)?;
155    o.set("name", self.name.clone())?;
156    o.set("entryType", self.entry_type.clone())?;
157    o.set("startTime", self.start_time)?;
158    o.set("duration", self.duration)?;
159    Ok(o)
160  }
161}
162
163/// `PerformanceMark` — a `PerformanceEntry` plus the `detail` its
164/// creator attached.
165#[derive(Trace)]
166#[rquickjs::class(rename = "PerformanceMark")]
167pub struct PerformanceMarkJs<'js> {
168  #[qjs(skip_trace)]
169  name: String,
170  #[qjs(skip_trace)]
171  start_time: f64,
172  detail: Value<'js>,
173}
174
175#[allow(unsafe_code)]
176unsafe impl<'js> rquickjs::JsLifetime<'js> for PerformanceMarkJs<'js> {
177  type Changed<'to> = PerformanceMarkJs<'to>;
178}
179
180#[rquickjs::methods(rename_all = "camelCase")]
181impl<'js> PerformanceMarkJs<'js> {
182  /// `new PerformanceMark(name, { detail?, startTime? })`. Unlike
183  /// `performance.mark()`, a directly constructed mark is NOT added to
184  /// the timeline — the spec buffers only what `mark()` records.
185  #[qjs(constructor)]
186  fn constructor(ctx: Ctx<'js>, name: String, options: Opt<Value<'js>>) -> rquickjs::Result<Self> {
187    let (start_time, detail) = mark_options(&ctx, options.0.as_ref())?;
188    Ok(Self {
189      name,
190      start_time,
191      detail,
192    })
193  }
194
195  #[qjs(get)]
196  fn name(&self) -> String {
197    self.name.clone()
198  }
199
200  #[qjs(get)]
201  fn entry_type(&self) -> &'static str {
202    MARK
203  }
204
205  #[qjs(get)]
206  fn start_time(&self) -> f64 {
207    self.start_time
208  }
209
210  /// Always 0: a mark is an instant, not an interval.
211  #[qjs(get)]
212  fn duration(&self) -> f64 {
213    0.0
214  }
215
216  #[qjs(get)]
217  fn detail(&self) -> Value<'js> {
218    self.detail.clone()
219  }
220
221  /// `detail` is included, matching what browsers serialize. The IDL's
222  /// default serializer covers only `PerformanceEntry`'s own
223  /// attributes, so this is the more useful reading of an ambiguity
224  /// rather than a strict one.
225  #[qjs(rename = "toJSON")]
226  fn to_json(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
227    let o = Object::new(ctx)?;
228    o.set("name", self.name.clone())?;
229    o.set("entryType", MARK)?;
230    o.set("startTime", self.start_time)?;
231    o.set("duration", 0.0)?;
232    o.set("detail", self.detail.clone())?;
233    Ok(o)
234  }
235}
236
237/// `PerformanceMeasure` — an interval between two points on the
238/// timeline.
239#[derive(Trace)]
240#[rquickjs::class(rename = "PerformanceMeasure")]
241pub struct PerformanceMeasureJs<'js> {
242  #[qjs(skip_trace)]
243  name: String,
244  #[qjs(skip_trace)]
245  start_time: f64,
246  #[qjs(skip_trace)]
247  duration: f64,
248  detail: Value<'js>,
249}
250
251#[allow(unsafe_code)]
252unsafe impl<'js> rquickjs::JsLifetime<'js> for PerformanceMeasureJs<'js> {
253  type Changed<'to> = PerformanceMeasureJs<'to>;
254}
255
256#[rquickjs::methods(rename_all = "camelCase")]
257impl<'js> PerformanceMeasureJs<'js> {
258  /// Not constructible, same as `PerformanceEntry`: a measure only ever
259  /// comes from `performance.measure()`.
260  #[qjs(constructor)]
261  fn constructor(ctx: Ctx<'js>) -> rquickjs::Result<Self> {
262    Err(Exception::throw_type(&ctx, "Illegal constructor"))
263  }
264
265  #[qjs(get)]
266  fn name(&self) -> String {
267    self.name.clone()
268  }
269
270  #[qjs(get)]
271  fn entry_type(&self) -> &'static str {
272    MEASURE
273  }
274
275  #[qjs(get)]
276  fn start_time(&self) -> f64 {
277    self.start_time
278  }
279
280  #[qjs(get)]
281  fn duration(&self) -> f64 {
282    self.duration
283  }
284
285  #[qjs(get)]
286  fn detail(&self) -> Value<'js> {
287    self.detail.clone()
288  }
289
290  #[qjs(rename = "toJSON")]
291  fn to_json(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
292    let o = Object::new(ctx)?;
293    o.set("name", self.name.clone())?;
294    o.set("entryType", MEASURE)?;
295    o.set("startTime", self.start_time)?;
296    o.set("duration", self.duration)?;
297    o.set("detail", self.detail.clone())?;
298    Ok(o)
299  }
300}
301
302/// One buffered entry.
303///
304/// The name / type / start time are kept Rust-side alongside the JS
305/// value so a `measure` resolving a mark name, and every
306/// `getEntriesBy*` filter, is a Rust comparison rather than a property
307/// read back out of the interpreter for each candidate.
308#[derive(Trace)]
309struct Buffered<'js> {
310  #[qjs(skip_trace)]
311  name: String,
312  #[qjs(skip_trace)]
313  is_mark: bool,
314  #[qjs(skip_trace)]
315  start_time: f64,
316  value: Value<'js>,
317}
318
319/// `performance`.
320#[derive(Trace)]
321#[rquickjs::class(rename = "Performance")]
322pub struct PerformanceJs<'js> {
323  entries: Vec<Buffered<'js>>,
324}
325
326#[allow(unsafe_code)]
327unsafe impl<'js> rquickjs::JsLifetime<'js> for PerformanceJs<'js> {
328  type Changed<'to> = PerformanceJs<'to>;
329}
330
331impl Default for PerformanceJs<'_> {
332  fn default() -> Self {
333    Self::new()
334  }
335}
336
337impl<'js> PerformanceJs<'js> {
338  #[must_use]
339  pub fn new() -> Self {
340    Self { entries: Vec::new() }
341  }
342
343  /// The start time a mark name resolves to: the MOST RECENT mark with
344  /// that name, per User Timing's "convert a mark to a timestamp".
345  fn resolve_mark(&self, ctx: &Ctx<'js>, name: &str) -> rquickjs::Result<f64> {
346    self
347      .entries
348      .iter()
349      .rev()
350      .find(|e| e.is_mark && e.name == name)
351      .map(|e| e.start_time)
352      .ok_or_else(|| Exception::throw_syntax(ctx, &format!("the mark {name:?} does not exist")))
353  }
354
355  /// A `start` / `end` member of `PerformanceMeasureOptions`, or a mark
356  /// name. A number must be non-negative; a string names a mark.
357  fn resolve_timestamp(&self, ctx: &Ctx<'js>, value: &Value<'js>) -> rquickjs::Result<f64> {
358    if let Some(name) = value.as_string() {
359      return self.resolve_mark(ctx, &name.to_string()?);
360    }
361    let Some(n) = value.as_number() else {
362      return Err(Exception::throw_type(
363        ctx,
364        "a performance timestamp must be a mark name or a number",
365      ));
366    };
367    if n < 0.0 {
368      return Err(Exception::throw_type(ctx, "a performance timestamp cannot be negative"));
369    }
370    Ok(n)
371  }
372}
373
374#[rquickjs::methods(rename_all = "camelCase")]
375impl<'js> PerformanceJs<'js> {
376  #[qjs(constructor)]
377  fn constructor(ctx: Ctx<'js>) -> rquickjs::Result<Self> {
378    Err(Exception::throw_type(&ctx, "Illegal constructor"))
379  }
380
381  /// Unix-epoch milliseconds the monotonic readings are relative to.
382  #[qjs(get)]
383  fn time_origin(&self) -> f64 {
384    time_origin_ms()
385  }
386
387  /// Monotonic milliseconds since `timeOrigin`.
388  fn now(&self) -> f64 {
389    now_ms()
390  }
391
392  #[qjs(rename = "toJSON")]
393  fn to_json(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
394    let o = Object::new(ctx)?;
395    o.set("timeOrigin", time_origin_ms())?;
396    Ok(o)
397  }
398
399  /// `mark(name, { detail?, startTime? })`.
400  fn mark(&mut self, ctx: Ctx<'js>, name: String, options: Opt<Value<'js>>) -> rquickjs::Result<Value<'js>> {
401    let (start_time, detail) = mark_options(&ctx, options.0.as_ref())?;
402
403    let entry = Class::instance(
404      ctx.clone(),
405      PerformanceMarkJs {
406        name: name.clone(),
407        start_time,
408        detail,
409      },
410    )?;
411    let value = entry.into_value();
412    self.entries.push(Buffered {
413      name,
414      is_mark: true,
415      start_time,
416      value: value.clone(),
417    });
418    Ok(value)
419  }
420
421  /// `measure(name, startMarkOrOptions?, endMark?)`.
422  ///
423  /// The three-way overload the spec defines: a bare name measures from
424  /// the time origin to now; a string names the start mark; an options
425  /// bag carries any two of `start` / `end` / `duration` and the third
426  /// is derived.
427  fn measure(
428    &mut self,
429    ctx: Ctx<'js>,
430    name: String,
431    start_or_options: Opt<Value<'js>>,
432    end_mark: Opt<Value<'js>>,
433  ) -> rquickjs::Result<Value<'js>> {
434    let arg = start_or_options.0.filter(|v| !v.is_undefined());
435    let end_arg = end_mark.0.filter(|v| !v.is_undefined());
436    let options = arg.as_ref().and_then(|v| v.as_object()).filter(|o| !o.is_array());
437
438    let mut detail = Value::new_null(ctx.clone());
439    let (start_time, duration) = if let Some(options) = options {
440      let get = |key: &str| -> Option<Value<'js>> {
441        options.get::<_, Value<'js>>(key).ok().filter(|v| !v.is_undefined())
442      };
443      let (start, end, dur) = (get("start"), get("end"), get("duration"));
444
445      // Both an options bag and a trailing endMark is ambiguous about
446      // which one wins, so the spec refuses rather than picking.
447      if end_arg.is_some() {
448        return Err(Exception::throw_type(
449          &ctx,
450          "measure() takes an endMark or a PerformanceMeasureOptions object, not both",
451        ));
452      }
453      if start.is_none() && end.is_none() && dur.is_none() {
454        return Err(Exception::throw_type(
455          &ctx,
456          "PerformanceMeasureOptions must set at least one of start, end or duration",
457        ));
458      }
459      // All three over-constrain the interval: they can disagree.
460      if start.is_some() && end.is_some() && dur.is_some() {
461        return Err(Exception::throw_type(
462          &ctx,
463          "PerformanceMeasureOptions cannot set all of start, end and duration",
464        ));
465      }
466      if let Some(given) = get("detail") {
467        detail = given;
468      }
469
470      let dur = dur.map(|d| self.resolve_timestamp(&ctx, &d)).transpose()?;
471      let start = start.map(|s| self.resolve_timestamp(&ctx, &s)).transpose()?;
472      let end = end.map(|e| self.resolve_timestamp(&ctx, &e)).transpose()?;
473
474      match (start, end, dur) {
475        (Some(s), Some(e), _) => (s, e - s),
476        (Some(s), None, Some(d)) => (s, d),
477        (None, Some(e), Some(d)) => (e - d, d),
478        (Some(s), None, None) => (s, now_ms() - s),
479        (None, Some(e), None) => (0.0, e),
480        (None, None, Some(d)) => (now_ms() - d, d),
481        (None, None, None) => unreachable!("the all-absent case is refused above"),
482      }
483    } else {
484      let start = match &arg {
485        Some(v) => self.resolve_timestamp(&ctx, v)?,
486        None => 0.0,
487      };
488      let end = match &end_arg {
489        Some(v) => self.resolve_timestamp(&ctx, v)?,
490        None => now_ms(),
491      };
492      (start, end - start)
493    };
494
495    let entry = Class::instance(
496      ctx.clone(),
497      PerformanceMeasureJs {
498        name: name.clone(),
499        start_time,
500        duration,
501        detail,
502      },
503    )?;
504    let value = entry.into_value();
505    self.entries.push(Buffered {
506      name,
507      is_mark: false,
508      start_time,
509      value: value.clone(),
510    });
511    Ok(value)
512  }
513
514  /// Drop every mark, or every mark with `name`.
515  fn clear_marks(&mut self, name: Opt<String>) {
516    match name.0 {
517      Some(name) => self.entries.retain(|e| !(e.is_mark && e.name == name)),
518      None => self.entries.retain(|e| !e.is_mark),
519    }
520  }
521
522  /// Drop every measure, or every measure with `name`.
523  fn clear_measures(&mut self, name: Opt<String>) {
524    match name.0 {
525      Some(name) => self.entries.retain(|e| !(!e.is_mark && e.name == name)),
526      None => self.entries.retain(|e| e.is_mark),
527    }
528  }
529
530  /// Every entry, in chronological order of `startTime`.
531  ///
532  /// Insertion order is not enough: `mark(name, { startTime })` can
533  /// backdate an entry, so a later call may belong earlier on the
534  /// timeline. The sort is stable, so entries sharing a `startTime`
535  /// keep the order they were recorded in.
536  fn get_entries(&self) -> Vec<Value<'js>> {
537    let mut out: Vec<&Buffered<'js>> = self.entries.iter().collect();
538    out.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
539    out.into_iter().map(|e| e.value.clone()).collect()
540  }
541
542  fn get_entries_by_name(&self, name: String, entry_type: Opt<String>) -> Vec<Value<'js>> {
543    let wanted = entry_type.0;
544    let mut out: Vec<&Buffered<'js>> = self
545      .entries
546      .iter()
547      .filter(|e| e.name == name)
548      .filter(|e| match wanted.as_deref() {
549        Some(t) => t == if e.is_mark { MARK } else { MEASURE },
550        None => true,
551      })
552      .collect();
553    out.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
554    out.into_iter().map(|e| e.value.clone()).collect()
555  }
556
557  fn get_entries_by_type(&self, entry_type: String) -> Vec<Value<'js>> {
558    let want_mark = entry_type == MARK;
559    if !want_mark && entry_type != MEASURE {
560      return Vec::new();
561    }
562    let mut out: Vec<&Buffered<'js>> = self.entries.iter().filter(|e| e.is_mark == want_mark).collect();
563    out.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
564    out.into_iter().map(|e| e.value.clone()).collect()
565  }
566}
567
568/// Define the four classes and install the `performance` instance.
569///
570/// # Errors
571///
572/// Propagates the class definitions and the global write.
573pub fn init(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
574  let globals = ctx.globals();
575  Class::<PerformanceEntryJs>::define(&globals)?;
576  Class::<PerformanceMarkJs>::define(&globals)?;
577  Class::<PerformanceMeasureJs>::define(&globals)?;
578  Class::<PerformanceJs>::define(&globals)?;
579  chain_entry_prototypes(ctx)?;
580
581  let performance = Class::instance(ctx.clone(), PerformanceJs::new())?;
582  globals.set("performance", performance)?;
583  Ok(())
584}
585
586/// Point `PerformanceMark.prototype` and `PerformanceMeasure.prototype`
587/// at `PerformanceEntry.prototype`, which is what makes a mark an
588/// instance of `PerformanceEntry` — the relationship the timeline's
589/// whole type hierarchy is expressed in.
590fn chain_entry_prototypes(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
591  let Some(entry_proto) = Class::<PerformanceEntryJs>::prototype(ctx)? else {
592    return Ok(());
593  };
594  if let Some(mark_proto) = Class::<PerformanceMarkJs>::prototype(ctx)? {
595    mark_proto.set_prototype(Some(&entry_proto))?;
596  }
597  if let Some(measure_proto) = Class::<PerformanceMeasureJs>::prototype(ctx)? {
598    measure_proto.set_prototype(Some(&entry_proto))?;
599  }
600  Ok(())
601}