Skip to main content

nodejs/stdlib/
perf_hooks.rs

1//! Node `perf_hooks` module.
2//!
3//! Exposes the `performance` object. `performance.now()` returns REAL monotonic
4//! milliseconds elapsed since a process-start reference captured lazily in a
5//! `OnceLock` (`std::time::Instant` — a true monotonic clock, never faked or
6//! fuzzed). `timeOrigin` is the wall-clock time (Unix ms) at that same reference
7//! point, so `timeOrigin + now()` approximates `Date.now()` as Node guarantees.
8//!
9//! `mark`/`measure`/`getEntriesByName`/`getEntriesByType`/`clearMarks` are
10//! implemented against a small in-process entry buffer guarded by a `Mutex`. This
11//! is best-effort: entries accumulate for the life of the process and the buffer
12//! is not bounded (Node's PerformanceObserver / buffered-entry eviction is not
13//! modeled), but marks and measures created and queried within a run behave
14//! correctly.
15//!
16//! `performance` is surfaced as a `Builtin("performance")` namespace value, so
17//! `performance.now()` dispatches through `call_method` → `call_builtin_function`
18//! ("performance.now") → this module's `call`, and `performance.timeOrigin`
19//! reads through `namespace_property` → this module's `constant`. The parent wires
20//! BOTH the `perf_hooks` and `performance` namespaces to `call`/`constant` (see
21//! the wiring note in the accompanying report).
22
23use crate::host::{with_host, JsObj};
24use fusevm::Value;
25use indexmap::IndexMap;
26use std::cell::RefCell;
27use std::sync::{Mutex, OnceLock};
28use std::time::Instant;
29
30/// Methods available on both the `perf_hooks` module and its `performance`
31/// object. (`timeOrigin` is a data property, served by `constant`.)
32pub const METHODS: &[&str] = &[
33    "now",
34    "mark",
35    "measure",
36    "getEntriesByName",
37    "getEntriesByType",
38    "getEntries",
39    "clearMarks",
40    "clearMeasures",
41    "createHistogram",
42    "eventLoopUtilization",
43    "monitorEventLoopDelay",
44    "timerify",
45    // Internal hook the `timerify` wrapper calls to deliver its 'function' entry
46    // (hidden `@@` name — not a user-facing method, only reachable by the wrapper).
47    "@@timerify_record",
48];
49
50/// Methods dispatched on an `@@native = "Histogram"` object (from
51/// `createHistogram()` / `monitorEventLoopDelay()`; reported to the parent for
52/// `instance_has_method` / `instance_call` wiring).
53pub const HISTOGRAM_METHODS: &[&str] = &[
54    "record",
55    "recordDelta",
56    "reset",
57    "percentile",
58    "add",
59    "enable",
60    "disable",
61];
62
63/// Methods dispatched on an `@@native = "PerformanceObserver"` object.
64pub const PERFORMANCE_OBSERVER_METHODS: &[&str] = &["observe", "disconnect", "takeRecords"];
65
66/// Methods dispatched on an `@@native = "PerformanceObserverEntryList"` object.
67pub const OBSERVER_ENTRY_LIST_METHODS: &[&str] =
68    &["getEntries", "getEntriesByName", "getEntriesByType"];
69
70/// Node's sentinel `min` for an empty histogram (`i64::MAX`).
71const EMPTY_HISTOGRAM_MIN: f64 = 9_223_372_036_854_775_807.0;
72
73thread_local! {
74    /// Live `PerformanceObserver` objects that should be notified when a mark or
75    /// measure is recorded (only ever touched on the thread that owns them).
76    static OBSERVERS: RefCell<Vec<Value>> = const { RefCell::new(Vec::new()) };
77}
78
79/// The process-start reference: a monotonic `Instant` paired with the Unix-epoch
80/// milliseconds at the same moment. Captured once, lazily.
81struct Origin {
82    instant: Instant,
83    unix_ms: f64,
84}
85
86fn origin() -> &'static Origin {
87    static ORIGIN: OnceLock<Origin> = OnceLock::new();
88    ORIGIN.get_or_init(|| Origin {
89        instant: Instant::now(),
90        unix_ms: std::time::SystemTime::now()
91            .duration_since(std::time::UNIX_EPOCH)
92            .map(|d| d.as_secs_f64() * 1000.0)
93            .unwrap_or(0.0),
94    })
95}
96
97/// Real monotonic milliseconds since the process-start reference.
98fn now_ms() -> f64 {
99    origin().instant.elapsed().as_secs_f64() * 1000.0
100}
101
102/// A recorded performance entry (`PerformanceEntry` shape).
103#[derive(Clone)]
104struct Entry {
105    name: String,
106    entry_type: &'static str,
107    start_time: f64,
108    duration: f64,
109}
110
111/// The in-process entry buffer (marks + measures), in insertion order.
112fn entries() -> &'static Mutex<Vec<Entry>> {
113    static ENTRIES: OnceLock<Mutex<Vec<Entry>>> = OnceLock::new();
114    ENTRIES.get_or_init(|| Mutex::new(Vec::new()))
115}
116
117/// Non-function properties of `perf_hooks` / `performance`.
118///
119/// `perf_hooks.performance` → the `performance` namespace. `performance.timeOrigin`
120/// → the fixed Unix-ms origin. `perf_hooks.constants` → a (currently empty) map.
121pub fn constant(name: &str) -> Option<Value> {
122    match name {
123        "performance" => Some(with_host(|h| h.alloc(JsObj::Builtin("performance".into())))),
124        "timeOrigin" => Some(Value::Float(origin().unix_ms)),
125        "constants" => Some(with_host(|h| h.new_object(IndexMap::new()))),
126        // Constructor names, exposed as values so `require('perf_hooks').X`
127        // resolves and `typeof X === 'function'` holds. Only `PerformanceObserver`
128        // is meaningfully instantiable here (see `construct`); the others exist for
129        // name/`instanceof` resolution. Parent wires `PerformanceObserver`
130        // construction into `construct`.
131        "Performance"
132        | "PerformanceEntry"
133        | "PerformanceMark"
134        | "PerformanceMeasure"
135        | "PerformanceObserver"
136        | "PerformanceObserverEntryList"
137        | "PerformanceResourceTiming" => Some(with_host(|h| h.alloc(JsObj::Builtin(name.into())))),
138        _ => None,
139    }
140}
141
142pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
143    Some(match method {
144        "now" => Ok(Value::Float(now_ms())),
145        "mark" => Ok(mark(args)),
146        "measure" => Ok(measure(args)),
147        "getEntries" => Ok(entries_to_array(|_| true)),
148        "getEntriesByName" => {
149            let name = super::arg_str(args, 0);
150            // Optional second arg filters by entryType (an explicit `undefined`
151            // means "no filter", matching Node).
152            let ty = match args.get(1) {
153                Some(v) if !matches!(v, Value::Undef) => Some(super::arg_str(args, 1)),
154                _ => None,
155            };
156            Ok(entries_to_array(|e| {
157                e.name == name && ty.as_deref().map(|t| t == e.entry_type).unwrap_or(true)
158            }))
159        }
160        "getEntriesByType" => {
161            let ty = super::arg_str(args, 0);
162            Ok(entries_to_array(|e| e.entry_type == ty))
163        }
164        "clearMarks" => Ok(clear("mark", args)),
165        "clearMeasures" => Ok(clear("measure", args)),
166        // A real histogram over recorded values (see `histogram_instance_call`).
167        "createHistogram" => Ok(new_histogram()),
168        "eventLoopUtilization" => Ok(event_loop_utilization(args)),
169        // A histogram-shaped monitor. LIMITATION: node-js has no background event-
170        // loop-delay sampler, so this histogram accumulates no samples on its own
171        // (it stays empty until values are `record`ed manually). `enable`/`disable`
172        // are no-ops. Honest empty data, never a fabricated delay distribution.
173        "monitorEventLoopDelay" => Ok(new_histogram()),
174        "timerify" => timerify(args),
175        "@@timerify_record" => Ok(timerify_record(args)),
176        _ => return None,
177    })
178}
179
180// ── timerify ──────────────────────────────────────────────────────────────────
181// `performance.timerify(fn)` wraps `fn` so each call records a 'function'
182// PerformanceEntry (name = `fn.name`, duration = call time). The wrapper is a REAL
183// JS closure compiled + invoked here (the same re-entrant factory technique
184// `util.promisify` uses), closing over the original function and a native record
185// hook (`Builtin("performance.@@timerify_record")`). Node delivers 'function'
186// entries to subscribed PerformanceObservers only — they are NOT retained on the
187// global timeline (`performance.getEntriesByType('function')` is empty in v26) — so
188// the hook notifies observers without buffering the entry.
189
190/// Compile a single JS expression and run it on the LIVE host, returning its
191/// completion value. Delegates to the frontend's ONE runtime-source evaluator
192/// (`crate::eval_in_global_scope`), which runs the factory in the program's
193/// module scope rather than in the calling function's frame.
194fn run_completion(src: &str) -> Result<Value, String> {
195    crate::eval_in_global_scope(src)
196}
197
198const TIMERIFY_SRC: &str = "(function(original, record){\n\
199  var perf = require('perf_hooks').performance;\n\
200  return function(){\n\
201    var start = perf.now();\n\
202    try {\n\
203      return original.apply(this, arguments);\n\
204    } finally {\n\
205      record(original.name || '', start, perf.now());\n\
206    }\n\
207  };\n\
208})";
209
210/// `performance.timerify(fn[, options])` → a wrapped `fn` that records a 'function'
211/// `PerformanceEntry` (its call duration) on every invocation.
212fn timerify(args: &[Value]) -> Result<Value, String> {
213    let orig = args.first().cloned().unwrap_or(Value::Undef);
214    if !with_host(|h| crate::host::is_callable(h, &orig)) {
215        return Err(crate::host::invalid_arg_type(
216            "fn", "argument", "function", &orig,
217        ));
218    }
219    let factory = run_completion(TIMERIFY_SRC)?;
220    let record = with_host(|h| h.alloc(JsObj::Builtin("performance.@@timerify_record".into())));
221    crate::host::invoke(&factory, vec![orig, record], None)
222}
223
224/// Native hook invoked by the `timerify` wrapper `(name, startTime, endTime)`:
225/// deliver a 'function' entry (call duration) to subscribed observers. Not stored
226/// on the global timeline — matching Node v26, where function entries reach
227/// observers only.
228fn timerify_record(args: &[Value]) -> Value {
229    let name = super::arg_str(args, 0);
230    let start = super::arg_num(args, 1);
231    let end = super::arg_num(args, 2);
232    let e = Entry {
233        name,
234        entry_type: "function",
235        start_time: start,
236        duration: (end - start).max(0.0),
237    };
238    notify_observers(&e);
239    Value::Undef
240}
241
242/// `new PerformanceObserver(callback)` — build an observer holding its callback,
243/// its subscribed entry types, and a pending-entries buffer. Reported to the
244/// parent for `construct` wiring.
245pub fn construct(name: &str, args: &[Value]) -> Result<Value, String> {
246    match name {
247        "PerformanceObserver" => {
248            let cb = args.first().cloned().unwrap_or(Value::Undef);
249            Ok(with_host(|h| {
250                let types = h.new_array(Vec::new());
251                let buffer = h.new_array(Vec::new());
252                let mut m = IndexMap::new();
253                m.insert("@@native".into(), h.new_str("PerformanceObserver"));
254                m.insert("@@cb".into(), cb);
255                m.insert("@@types".into(), types);
256                m.insert("@@buffer".into(), buffer);
257                h.new_object(m)
258            }))
259        }
260        _ => Err(crate::host::type_error(&format!(
261            "perf_hooks.{name} is not a constructor"
262        ))),
263    }
264}
265
266/// `performance.mark(name)`: record a mark entry at the current time and return a
267/// `PerformanceEntry` for it.
268fn mark(args: &[Value]) -> Value {
269    let name = super::arg_str(args, 0);
270    let start = now_ms();
271    let e = Entry {
272        name,
273        entry_type: "mark",
274        start_time: start,
275        duration: 0.0,
276    };
277    if let Ok(mut buf) = entries().lock() {
278        buf.push(e.clone());
279    }
280    notify_observers(&e);
281    entry_object(&e)
282}
283
284/// `performance.measure(name, startMark, endMark)`: record a measure spanning two
285/// previously recorded marks (missing marks default to `0`/now), returning its
286/// `PerformanceEntry`.
287fn measure(args: &[Value]) -> Value {
288    let name = super::arg_str(args, 0);
289    let start_mark = args.get(1).map(|_| super::arg_str(args, 1));
290    let end_mark = args.get(2).map(|_| super::arg_str(args, 2));
291    let mark_time = |m: &Option<String>, default: f64| -> f64 {
292        match m {
293            Some(n) => entries()
294                .lock()
295                .ok()
296                .and_then(|b| {
297                    b.iter()
298                        .rev()
299                        .find(|e| e.entry_type == "mark" && &e.name == n)
300                        .map(|e| e.start_time)
301                })
302                .unwrap_or(default),
303            None => default,
304        }
305    };
306    let start = mark_time(&start_mark, 0.0);
307    let end = mark_time(&end_mark, now_ms());
308    let e = Entry {
309        name,
310        entry_type: "measure",
311        start_time: start,
312        duration: (end - start).max(0.0),
313    };
314    if let Ok(mut buf) = entries().lock() {
315        buf.push(e.clone());
316    }
317    notify_observers(&e);
318    entry_object(&e)
319}
320
321/// `clearMarks([name])` / `clearMeasures([name])`: drop entries of the given kind
322/// (all, or only those named `name` when a name is supplied). Returns undefined.
323fn clear(kind: &'static str, args: &[Value]) -> Value {
324    let name = args.first().map(|_| super::arg_str(args, 0));
325    if let Ok(mut buf) = entries().lock() {
326        buf.retain(|e| {
327            if e.entry_type != kind {
328                return true;
329            }
330            match &name {
331                Some(n) => &e.name != n,
332                None => false,
333            }
334        });
335    }
336    Value::Undef
337}
338
339/// Build a JS array of `PerformanceEntry` objects for the buffered entries
340/// matching `pred`.
341fn entries_to_array(pred: impl Fn(&Entry) -> bool) -> Value {
342    let matched: Vec<Entry> = entries()
343        .lock()
344        .map(|b| b.iter().filter(|e| pred(e)).cloned().collect())
345        .unwrap_or_default();
346    with_host(|h| {
347        let items: Vec<Value> = matched.iter().map(|e| entry_object_h(h, e)).collect();
348        h.new_array(items)
349    })
350}
351
352/// Allocate a `PerformanceEntry`-shaped object.
353fn entry_object(e: &Entry) -> Value {
354    with_host(|h| entry_object_h(h, e))
355}
356
357fn entry_object_h(h: &mut crate::host::JsHost, e: &Entry) -> Value {
358    let mut m = IndexMap::new();
359    m.insert("name".into(), h.new_str(e.name.clone()));
360    m.insert("entryType".into(), h.new_str(e.entry_type));
361    m.insert("startTime".into(), Value::Float(e.start_time));
362    m.insert("duration".into(), Value::Float(e.duration));
363    h.new_object(m)
364}
365
366// ── histogram (createHistogram / monitorEventLoopDelay) ───────────────────────
367
368/// A fresh, empty histogram object. Recorded values accumulate in the hidden
369/// `@@vals` array; the `count`/`min`/`max`/`mean`/`stddev`/`exceeds` data
370/// properties are kept in sync on every `record`, so a plain property read
371/// (`h.min`) returns the right value without a getter.
372fn new_histogram() -> Value {
373    with_host(|h| {
374        let vals = h.new_array(Vec::new());
375        let mut m = IndexMap::new();
376        m.insert("@@native".into(), h.new_str("Histogram"));
377        m.insert("@@vals".into(), vals);
378        m.insert("count".into(), Value::Float(0.0));
379        m.insert("min".into(), Value::Float(EMPTY_HISTOGRAM_MIN));
380        m.insert("max".into(), Value::Float(0.0));
381        m.insert("mean".into(), Value::Float(f64::NAN));
382        m.insert("stddev".into(), Value::Float(f64::NAN));
383        m.insert("exceeds".into(), Value::Float(0.0));
384        h.new_object(m)
385    })
386}
387
388/// Dispatch a method on a `Histogram` instance (`@@native = "Histogram"`).
389pub fn histogram_instance_call(
390    recv: &Value,
391    method: &str,
392    args: &[Value],
393) -> Result<Value, String> {
394    match method {
395        "record" => {
396            let n = super::arg_num(args, 0);
397            push_value(recv, n);
398            update_stats(recv);
399            Ok(Value::Undef)
400        }
401        // Record the elapsed time (ms) since the previous `recordDelta` (or since
402        // the histogram was created, for the first call).
403        "recordDelta" => {
404            let now = now_ms();
405            let last = read_hidden_num(recv, "@@last").unwrap_or(now);
406            set_hidden_num(recv, "@@last", now);
407            if read_hidden_num(recv, "@@last_seen").is_some() {
408                push_value(recv, now - last);
409                update_stats(recv);
410            }
411            set_hidden_num(recv, "@@last_seen", 1.0);
412            Ok(Value::Undef)
413        }
414        "reset" => {
415            // `hidden` takes the host itself, so reading it INSIDE `with_host`
416            // borrowed the same RefCell twice and aborted the process with
417            // "RefCell already borrowed" — not a catchable JS error, the whole
418            // runtime died on `histogram.reset()`. Read the handle first, then
419            // borrow to mutate.
420            let vals = hidden(recv, "@@vals");
421            if let Some(vals) = vals {
422                with_host(|h| {
423                    if let Some(JsObj::Array(items)) = h.get_mut(&vals) {
424                        items.clear();
425                    }
426                });
427            }
428            update_stats(recv);
429            Ok(Value::Undef)
430        }
431        "percentile" => {
432            let p = super::arg_num(args, 0);
433            Ok(Value::Float(percentile(recv, p)))
434        }
435        "add" => {
436            // Merge another histogram's recorded values into this one.
437            if let Some(other) = args.first() {
438                for v in histogram_values(other) {
439                    push_value(recv, v);
440                }
441                update_stats(recv);
442            }
443            Ok(Value::Undef)
444        }
445        // Interval-form controls: no background sampler to toggle (see the
446        // `monitorEventLoopDelay` note). Accepted for compatibility.
447        "enable" | "disable" => Ok(Value::Bool(true)),
448        _ => Err(crate::host::type_error(&format!(
449            "{method} is not a function"
450        ))),
451    }
452}
453
454/// Push a recorded value onto the histogram's `@@vals` array.
455fn push_value(recv: &Value, n: f64) {
456    with_host(|h| {
457        let v = Value::Float(n);
458        if let Some(vals) = match h.get(recv) {
459            Some(JsObj::Object(p)) => p.get("@@vals").cloned(),
460            _ => None,
461        } {
462            if let Some(JsObj::Array(items)) = h.get_mut(&vals) {
463                items.push(v);
464            }
465        }
466    });
467}
468
469/// The recorded values of any histogram object, as `f64`s.
470fn histogram_values(recv: &Value) -> Vec<f64> {
471    with_host(|h| match h.get(recv) {
472        Some(JsObj::Object(p)) => match p.get("@@vals").and_then(|a| h.get(a)) {
473            Some(JsObj::Array(items)) => items.iter().map(|v| h.to_number(v)).collect(),
474            _ => Vec::new(),
475        },
476        _ => Vec::new(),
477    })
478}
479
480/// Recompute `count`/`min`/`max`/`mean`/`stddev` from `@@vals` and write them back.
481fn update_stats(recv: &Value) {
482    let vals = histogram_values(recv);
483    let (count, min, max, mean, stddev) = if vals.is_empty() {
484        (0.0, EMPTY_HISTOGRAM_MIN, 0.0, f64::NAN, f64::NAN)
485    } else {
486        let n = vals.len() as f64;
487        let sum: f64 = vals.iter().sum();
488        let mean = sum / n;
489        let var = vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
490        let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
491        let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
492        (n, min, max, mean, var.sqrt())
493    };
494    with_host(|h| {
495        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
496            p.insert("count".into(), Value::Float(count));
497            p.insert("min".into(), Value::Float(min));
498            p.insert("max".into(), Value::Float(max));
499            p.insert("mean".into(), Value::Float(mean));
500            p.insert("stddev".into(), Value::Float(stddev));
501        }
502    });
503}
504
505/// Nearest-rank percentile of the recorded values (empty → 0), matching Node's
506/// integer-valued percentile results for small samples.
507fn percentile(recv: &Value, p: f64) -> f64 {
508    let mut vals = histogram_values(recv);
509    if vals.is_empty() {
510        return 0.0;
511    }
512    vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
513    let n = vals.len();
514    let rank = (p / 100.0 * n as f64).ceil() as usize;
515    let idx = rank.clamp(1, n) - 1;
516    vals[idx]
517}
518
519/// A hidden own property of `recv`, if present.
520fn hidden(recv: &Value, key: &str) -> Option<Value> {
521    with_host(|h| match h.get(recv) {
522        Some(JsObj::Object(p)) => p.get(key).cloned(),
523        _ => None,
524    })
525}
526
527fn read_hidden_num(recv: &Value, key: &str) -> Option<f64> {
528    hidden(recv, key).map(|v| with_host(|h| h.to_number(&v)))
529}
530
531fn set_hidden_num(recv: &Value, key: &str, n: f64) {
532    with_host(|h| {
533        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
534            p.insert(key.to_string(), Value::Float(n));
535        }
536    });
537}
538
539// ── eventLoopUtilization ──────────────────────────────────────────────────────
540
541/// `performance.eventLoopUtilization([util1[, util2]])` → `{ idle, active,
542/// utilization }`.
543///
544/// LIMITATION (documented, not faked): node-js does not separately instrument the
545/// event loop's idle vs active time. The honest best-effort is `active` = total
546/// milliseconds elapsed since process start (real uptime) and `idle` = 0, so
547/// `utilization` = 1. When a prior result is passed, the numbers are the delta
548/// between it and now (Node's diff form).
549fn event_loop_utilization(args: &[Value]) -> Value {
550    let active_now = now_ms();
551    let (prev_idle, prev_active) = match args.first() {
552        Some(prev) => (
553            hidden_num(prev, "idle").unwrap_or(0.0),
554            hidden_num(prev, "active").unwrap_or(0.0),
555        ),
556        None => (0.0, 0.0),
557    };
558    let idle = 0.0 - prev_idle;
559    let active = active_now - prev_active;
560    let denom = idle + active;
561    let utilization = if denom > 0.0 { active / denom } else { 0.0 };
562    with_host(|h| {
563        let mut m = IndexMap::new();
564        m.insert("idle".into(), Value::Float(idle));
565        m.insert("active".into(), Value::Float(active));
566        m.insert("utilization".into(), Value::Float(utilization));
567        h.new_object(m)
568    })
569}
570
571fn hidden_num(recv: &Value, key: &str) -> Option<f64> {
572    with_host(|h| match h.get(recv) {
573        Some(JsObj::Object(p)) => p.get(key).map(|v| h.to_number(v)),
574        _ => None,
575    })
576}
577
578// ── PerformanceObserver ───────────────────────────────────────────────────────
579
580/// Dispatch a method on a `PerformanceObserver` instance.
581pub fn observer_instance_call(recv: &Value, method: &str, args: &[Value]) -> Result<Value, String> {
582    match method {
583        // `observe({ entryTypes: [...] } | { type: '...' })`: record the subscribed
584        // types and register so future marks/measures notify this observer.
585        "observe" => {
586            let opts = args.first().cloned().unwrap_or(Value::Undef);
587            let types = observe_types(&opts);
588            with_host(|h| {
589                let items: Vec<Value> = types.iter().map(|t| h.new_str(t.clone())).collect();
590                let arr = h.new_array(items);
591                if let Some(JsObj::Object(p)) = h.get_mut(recv) {
592                    p.insert("@@types".into(), arr);
593                }
594            });
595            OBSERVERS.with(|o| {
596                let mut list = o.borrow_mut();
597                if !list.iter().any(|v| same_ref(v, recv)) {
598                    list.push(recv.clone());
599                }
600            });
601            Ok(Value::Undef)
602        }
603        "disconnect" => {
604            OBSERVERS.with(|o| o.borrow_mut().retain(|v| !same_ref(v, recv)));
605            with_host(|h| {
606                if let Some(buf) = match h.get(recv) {
607                    Some(JsObj::Object(p)) => p.get("@@buffer").cloned(),
608                    _ => None,
609                } {
610                    if let Some(JsObj::Array(items)) = h.get_mut(&buf) {
611                        items.clear();
612                    }
613                }
614            });
615            Ok(Value::Undef)
616        }
617        // Drain and return the observer's buffered entries.
618        "takeRecords" => {
619            let taken: Vec<Value> = with_host(|h| match h.get(recv) {
620                Some(JsObj::Object(p)) => match p.get("@@buffer").and_then(|a| h.get(a)) {
621                    Some(JsObj::Array(items)) => items.clone(),
622                    _ => Vec::new(),
623                },
624                _ => Vec::new(),
625            });
626            with_host(|h| {
627                if let Some(buf) = match h.get(recv) {
628                    Some(JsObj::Object(p)) => p.get("@@buffer").cloned(),
629                    _ => None,
630                } {
631                    if let Some(JsObj::Array(items)) = h.get_mut(&buf) {
632                        items.clear();
633                    }
634                }
635            });
636            Ok(with_host(|h| h.new_array(taken)))
637        }
638        _ => Err(crate::host::type_error(&format!(
639            "{method} is not a function"
640        ))),
641    }
642}
643
644/// The entry types an `observe(options)` call subscribes to (`entryTypes` array
645/// or a single `type`).
646fn observe_types(opts: &Value) -> Vec<String> {
647    with_host(|h| match h.get(opts) {
648        Some(JsObj::Object(p)) => {
649            if let Some(JsObj::Array(items)) = p.get("entryTypes").and_then(|a| h.get(a)) {
650                items.iter().map(|v| h.str_of(v)).collect()
651            } else if let Some(t) = p.get("type") {
652                vec![h.str_of(t)]
653            } else {
654                Vec::new()
655            }
656        }
657        _ => Vec::new(),
658    })
659}
660
661/// Deliver a just-recorded entry to every subscribed observer.
662///
663/// DEVIATION (documented): Node batches entries and delivers them to the observer
664/// callback asynchronously on a microtask. node-js delivers SYNCHRONOUSLY, one
665/// entry per notification, right after the mark/measure is recorded. The callback
666/// receives `(entryList, observer)` exactly as Node's does.
667fn notify_observers(e: &Entry) {
668    let observers: Vec<Value> = OBSERVERS.with(|o| o.borrow().clone());
669    if observers.is_empty() {
670        return;
671    }
672    for obs in observers {
673        let types: Vec<String> = with_host(|h| match h.get(&obs) {
674            Some(JsObj::Object(p)) => match p.get("@@types").and_then(|a| h.get(a)) {
675                Some(JsObj::Array(items)) => items.iter().map(|v| h.str_of(v)).collect(),
676                _ => Vec::new(),
677            },
678            _ => Vec::new(),
679        });
680        if !types.iter().any(|t| t == e.entry_type) {
681            continue;
682        }
683        // Buffer the entry on the observer, then invoke its callback with a
684        // single-entry list.
685        let entry = entry_object(e);
686        with_host(|h| {
687            if let Some(buf) = match h.get(&obs) {
688                Some(JsObj::Object(p)) => p.get("@@buffer").cloned(),
689                _ => None,
690            } {
691                if let Some(JsObj::Array(items)) = h.get_mut(&buf) {
692                    items.push(entry);
693                }
694            }
695        });
696        let cb = with_host(|h| match h.get(&obs) {
697            Some(JsObj::Object(p)) => p.get("@@cb").cloned(),
698            _ => None,
699        });
700        let Some(cb) = cb else { continue };
701        let list = entry_list_object(vec![entry_object(e)]);
702        let _ = crate::host::invoke(&cb, vec![list, obs.clone()], None);
703    }
704}
705
706/// Build a `PerformanceObserverEntryList` wrapping `items`.
707fn entry_list_object(items: Vec<Value>) -> Value {
708    with_host(|h| {
709        let arr = h.new_array(items);
710        let mut m = IndexMap::new();
711        m.insert("@@native".into(), h.new_str("PerformanceObserverEntryList"));
712        m.insert("@@entries".into(), arr);
713        h.new_object(m)
714    })
715}
716
717/// Dispatch a method on a `PerformanceObserverEntryList` instance.
718pub fn entry_list_instance_call(
719    recv: &Value,
720    method: &str,
721    args: &[Value],
722) -> Result<Value, String> {
723    let items: Vec<Value> = with_host(|h| match h.get(recv) {
724        Some(JsObj::Object(p)) => match p.get("@@entries").and_then(|a| h.get(a)) {
725            Some(JsObj::Array(v)) => v.clone(),
726            _ => Vec::new(),
727        },
728        _ => Vec::new(),
729    });
730    let prop = |v: &Value, key: &str| {
731        with_host(|h| match h.get(v) {
732            Some(JsObj::Object(p)) => p.get(key).map(|x| h.str_of(x)),
733            _ => None,
734        })
735    };
736    match method {
737        "getEntries" => Ok(with_host(|h| h.new_array(items))),
738        "getEntriesByName" => {
739            let name = super::arg_str(args, 0);
740            let ty = match args.get(1) {
741                Some(v) if !matches!(v, Value::Undef) => Some(super::arg_str(args, 1)),
742                _ => None,
743            };
744            let filtered: Vec<Value> = items
745                .into_iter()
746                .filter(|it| {
747                    prop(it, "name").as_deref() == Some(name.as_str())
748                        && ty
749                            .as_deref()
750                            .map(|t| prop(it, "entryType").as_deref() == Some(t))
751                            .unwrap_or(true)
752                })
753                .collect();
754            Ok(with_host(|h| h.new_array(filtered)))
755        }
756        "getEntriesByType" => {
757            let ty = super::arg_str(args, 0);
758            let filtered: Vec<Value> = items
759                .into_iter()
760                .filter(|it| prop(it, "entryType").as_deref() == Some(ty.as_str()))
761                .collect();
762            Ok(with_host(|h| h.new_array(filtered)))
763        }
764        _ => Err(crate::host::type_error(&format!(
765            "{method} is not a function"
766        ))),
767    }
768}
769
770/// Heap-identity comparison for two reference values.
771fn same_ref(a: &Value, b: &Value) -> bool {
772    matches!((a, b), (Value::Obj(x), Value::Obj(y)) if x == y)
773}