From a4174af1b6ccd379cd8990b43c4b817cbfd409ac Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 8 Sep 2026 04:14:39 +0800 Subject: [PATCH] feat(performance): expose cached V8 memory snapshots --- .../context_bootstrap/performance_runtime.rs | 1 + .../performance_runtime/install.rs | 3 + .../performance_runtime/memory.rs | 165 ++++++++++++++++++ .../src/script_vm/tests/browser_api/mod.rs | 1 + .../tests/browser_api/performance_memory.rs | 80 +++++++++ .../src/worker/thread/tests/postmessage.rs | 5 +- 6 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/performance_runtime/memory.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/performance_memory.rs diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs index 7acaf99a2..5b943161a 100644 --- a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs @@ -5,6 +5,7 @@ mod entries; mod install; mod lazy_subobjects; mod marks_measures; +mod memory; mod resource_buffer; mod window_state; diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime/install.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime/install.rs index 76a397ca8..724e9f50b 100644 --- a/moli-renderer-v8/src/context_bootstrap/performance_runtime/install.rs +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime/install.rs @@ -161,6 +161,9 @@ struct PerformancePrototypeMethodsDeclaration { #[derive(WebApiFunctionTemplate)] #[webapi(name = "Performance", enumerable)] struct PerformancePrototypeAccessorsDeclaration { + #[webapi(accessor_property, getter = super::memory::performance_memory_getter)] + memory: (), + #[webapi( accessor_property, getter = performance_attribute_getter_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime/memory.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime/memory.rs new file mode 100644 index 000000000..b8dd39f7c --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime/memory.rs @@ -0,0 +1,165 @@ +use super::PERFORMANCE_TIME_ORIGIN_SLOT; +use crate::util::{ + callback_data_index_value, callback_data_item, get_private_value, set_private_value, + throw_type_error, +}; +use moli_webapi_declare::WebApiObject; +use std::time::{Duration, Instant}; + +const MEMORY_PROTOTYPE_SLOT: &str = "__moliPerformanceMemoryPrototype"; +const TOTAL_HEAP_SLOT: &str = "__moliMemoryInfoTotalHeap"; +const USED_HEAP_SLOT: &str = "__moliMemoryInfoUsedHeap"; +const HEAP_LIMIT_SLOT: &str = "__moliMemoryInfoHeapLimit"; +const MEMORY_SLOTS: &[&str] = &[TOTAL_HEAP_SLOT, USED_HEAP_SLOT, HEAP_LIMIT_SLOT]; +const SAMPLE_INTERVAL: Duration = Duration::from_secs(20 * 60); + +#[derive(Default, WebApiObject)] +#[webapi(interface = "Object")] +struct MemoryInfoPrototypeDeclaration { + #[webapi(accessor_property, name = "totalJSHeapSize", enumerable, getter = memory_info_getter, data = callback_data_index_value(scope, 0))] + total_js_heap_size: (), + + #[webapi(accessor_property, name = "usedJSHeapSize", enumerable, getter = memory_info_getter, data = callback_data_index_value(scope, 1))] + used_js_heap_size: (), + + #[webapi(accessor_property, name = "jsHeapSizeLimit", enumerable, getter = memory_info_getter, data = callback_data_index_value(scope, 2))] + js_heap_size_limit: (), + + #[webapi(to_string_tag, readonly, init = string("MemoryInfo"))] + tag: (), +} + +#[derive(WebApiObject)] +#[webapi(interface = "Object")] +struct MemoryInfoObjectDeclaration<'scope> { + #[webapi(prototype)] + prototype: Option>, + + #[webapi(slot = TOTAL_HEAP_SLOT)] + total: f64, + #[webapi(slot = USED_HEAP_SLOT)] + used: f64, + #[webapi(slot = HEAP_LIMIT_SLOT)] + limit: f64, +} + +#[derive(Clone, Copy)] +struct HeapSample { + taken_at: Instant, + sizes: [f64; 3], +} + +pub(super) fn performance_memory_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if get_private_value(scope, args.this(), PERFORMANCE_TIME_ORIGIN_SLOT).is_none() { + throw_type_error(scope, "Illegal invocation"); + return; + } + let sizes = heap_sample(scope); + let Some(context) = args.this().get_creation_context(scope) else { + return; + }; + let scope = &mut v8::ContextScope::new(scope, context); + let prototype = match get_private_value(scope, args.this(), MEMORY_PROTOTYPE_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + { + Some(prototype) => prototype, + None => { + // MemoryInfo is a legacy interface without a global constructor. + let Ok(prototype) = MemoryInfoPrototypeDeclaration::default().bind(scope) else { + return; + }; + set_private_value(scope, args.this(), MEMORY_PROTOTYPE_SLOT, prototype.into()); + prototype + } + }; + if let Ok(snapshot) = + MemoryInfoObjectDeclaration::new(Some(prototype), sizes[0], sizes[1], sizes[2]).bind(scope) + { + rv.set(snapshot.into()); + } +} + +fn memory_info_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(slot) = callback_data_item(scope, &args, MEMORY_SLOTS, "MemoryInfo attribute") else { + return; + }; + let Some(value) = get_private_value(scope, args.this(), slot) else { + throw_type_error(scope, "Illegal invocation"); + return; + }; + rv.set(value); +} + +fn heap_sample(scope: &mut v8::PinScope<'_, '_>) -> [f64; 3] { + let now = Instant::now(); + if let Some(sample) = scope.get_slot::() + && now.duration_since(sample.taken_at) < SAMPLE_INTERVAL + { + return sample.sizes; + } + let statistics = scope.get_heap_statistics(); + let external = statistics.external_memory(); + let sizes = [ + statistics.total_physical_size().saturating_add(external), + statistics.used_heap_size().saturating_add(external), + statistics.heap_size_limit(), + ] + .map(|size| quantize_heap_size(size) as f64); + scope.set_slot(HeapSample { + taken_at: now, + sizes, + }); + sizes +} + +// Chromium's legacy API exposes coarse, rate-limited V8 statistics. Use 100 +// exponentially spaced buckets from 10 MB towards 4 GB, rounded to three +// significant digits, instead of exposing fine-grained allocation/GC timing. +fn quantize_heap_size(size: usize) -> usize { + let growth = (400.0_f32.ln() / 100.0).exp(); + let mut boundary = 10_000_000.0_f32; + let mut decimal_threshold = 100_000_000_u64; + let mut precision = 100_000_u64; + let mut rounded = 0; + for _ in 0..100 { + rounded = (boundary as u64 / precision * precision) as usize; + if size <= rounded { + return rounded; + } + boundary *= growth; + if boundary >= decimal_threshold as f32 { + decimal_threshold *= 10; + precision *= 10; + } + } + rounded +} + +#[cfg(test)] +mod tests { + use super::quantize_heap_size; + + #[test] + fn memory_heap_buckets_are_coarse_monotonic_and_bounded() { + assert_eq!(quantize_heap_size(0), 10_000_000); + assert_eq!(quantize_heap_size(10_000_000), 10_000_000); + assert_eq!(quantize_heap_size(10_000_001), 10_600_000); + let mut previous = 0; + for size in (0..4_000_000_000_usize).step_by(1_000_000) { + let bucket = quantize_heap_size(size); + assert!(bucket >= previous); + assert_eq!(bucket % 100_000, 0); + previous = bucket; + } + assert_eq!(quantize_heap_size(usize::MAX), previous); + assert!((3_000_000_000..4_000_000_000).contains(&previous)); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 406769769..399e01c0b 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -20,6 +20,7 @@ mod media; mod misc; mod navigation; mod performance; +mod performance_memory; mod pointer_lock; mod promise_rejection; mod security_policy; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/performance_memory.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/performance_memory.rs new file mode 100644 index 000000000..81148ba00 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/performance_memory.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn performance_memory_exposes_readonly_branded_heap_snapshots_without_a_constructor() { + let mut vm = new_storage_test_vm("https://performance-memory.test/"); + let result = vm + .eval( + r#" + (() => { + const descriptor = Object.getOwnPropertyDescriptor(Performance.prototype, 'memory'); + const first = performance.memory; + const second = performance.memory; + const prototype = Object.getPrototypeOf(first); + const names = ['totalJSHeapSize', 'usedJSHeapSize', 'jsHeapSizeLimit']; + const rejects = callback => { + try { callback(); return false; } catch (error) { return error instanceof TypeError; } + }; + const checks = [ + descriptor.get.name === 'get memory', descriptor.get.length === 0, + descriptor.set === undefined, descriptor.enumerable, descriptor.configurable, + !Object.hasOwn(performance, 'memory'), !('MemoryInfo' in globalThis), + first !== second, Object.getPrototypeOf(second) === prototype, + Object.getPrototypeOf(prototype) === Object.prototype, + !Object.hasOwn(prototype, 'constructor'), + Object.prototype.toString.call(first) === '[object MemoryInfo]', + Object.getOwnPropertyNames(first).length === 0, + Object.getOwnPropertyNames(prototype).sort().join() === names.slice().sort().join(), + first.totalJSHeapSize >= first.usedJSHeapSize, + first.jsHeapSizeLimit >= first.usedJSHeapSize, + rejects(() => descriptor.get.call(Object.create(performance))), + rejects(() => descriptor.get.call({})) + ]; + for (const name of names) { + const property = Object.getOwnPropertyDescriptor(prototype, name); + const value = first[name]; + checks.push(Number.isSafeInteger(value), value >= 10000000, value % 100000 === 0, + property.get.name === 'get ' + name, property.get.length === 0, + property.set === undefined, property.enumerable, property.configurable); + first[name] = 0; + checks.push(first[name] === value, second[name] === value, + rejects(() => { 'use strict'; first[name] = 0; })); + for (const fake of [{}, prototype, Object.create(first), performance]) { + checks.push(rejects(() => property.get.call(fake))); + } + } + return checks.every(Boolean); + })() + "#, + ) + .expect("legacy memory snapshots should have branded readonly attributes"); + assert_eq!(result, "true"); +} + +#[test] +fn performance_memory_uses_the_receiver_realm_and_accepts_genuine_foreign_snapshots() { + let mut vm = new_parsed_test_vm( + "https://performance-memory-realms.test/", + "", + ); + let result = vm.eval(r#" + (() => { + const child = document.querySelector('iframe').contentWindow; + const getter = Object.getOwnPropertyDescriptor(Performance.prototype, 'memory').get; + const childGetter = Object.getOwnPropertyDescriptor(child.Performance.prototype, 'memory').get; + const localPrototype = Object.getPrototypeOf(performance.memory); + const childPrototype = Object.getPrototypeOf(child.performance.memory); + const foreign = getter.call(child.performance); + return [ + childPrototype !== localPrototype, + Object.getPrototypeOf(childPrototype) === child.Object.prototype, + Object.getPrototypeOf(foreign) === childPrototype, + Object.getPrototypeOf(childGetter.call(performance)) === localPrototype, + Object.getOwnPropertyDescriptor(localPrototype, 'usedJSHeapSize').get.call(foreign) + === foreign.usedJSHeapSize, + !('MemoryInfo' in child) + ].every(Boolean); + })() + "#).expect("memory snapshots should use their Performance object's realm"); + assert_eq!(result, "true"); +} diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index 23c70cd44..4329294d2 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -1844,7 +1844,8 @@ async fn worker_performance_now_uses_readonly_monotonic_time_origin() { readonly: descriptor && descriptor.writable === false, unchanged: after === before, numeric: typeof first === "number" && typeof second === "number", - monotonic: second >= first + monotonic: second >= first, + noLegacyMemory: !("memory" in performance) && !("MemoryInfo" in self) }); close(); "# @@ -1858,7 +1859,7 @@ async fn worker_performance_now_uses_readonly_monotonic_time_origin() { .expect("channel closed"); assert_eq!( expect_post_json(msg), - r#"{"readonly":true,"unchanged":true,"numeric":true,"monotonic":true}"# + r#"{"readonly":true,"unchanged":true,"numeric":true,"monotonic":true,"noLegacyMemory":true}"# ); }