fix(renderer): retain detached document state until realm collection

Retain native DOM, Blob, AbortSignal and intrinsic interface backing while
JavaScript still holds a retired realm. Retire active execution resources and
trace wrapper and traversal-filter ownership through V8 so the last reference
releases the native host without Rust-to-V8 root cycles.

Cover retained native values, realm finalizers and traversal callback lifetimes.

Validation: cargo fmt --all; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo nextest run --no-fail-fast.
This commit is contained in:
ldm0
2026-10-06 01:01:03 +08:00
committed by Donough Liu
parent 19667e531a
commit 9fd3e07f47
33 changed files with 1364 additions and 236 deletions
+50
View File
@@ -246,6 +246,32 @@ where
Some((String::from_utf8_lossy(&bytes).into_owned(), mime_type))
}
/// Revoke a departing context's URLs while preserving bytes retained by
/// wrappers, readers, or URLs created by another context.
pub fn retire_owner_resources(&self, owner_id: OwnerId) {
let retired = {
let mut urls = self.object_urls.lock();
let mut retired = Vec::new();
urls.retain(|_, state| {
if state.owner_id == Some(owner_id) {
retired.push(state.blob_id);
false
} else {
true
}
});
retired
};
for blob_id in retired {
self.release_blob_object_url_ref(blob_id);
}
for blob in self.blobs.lock().by_id.values_mut() {
if blob.owner_id == Some(owner_id) {
blob.owner_id = None;
}
}
}
/// Remove Blob/object URL entries owned by a context.
pub fn cleanup_owner_resources(&self, owner_id: OwnerId) {
let removed_blob_ids = {
@@ -504,6 +530,30 @@ mod tests {
assert!(store.blob_bytes_by_uuid_in_partition(&uuid, &10).is_none());
}
#[test]
fn retiring_owner_preserves_wrappers_readers_and_foreign_urls() {
let store = BlobStore::<u64, u64>::default();
let blob = store.create_blob(Some(1), Some(10), b"retained".to_vec(), "text/plain".into());
let own_url = store
.create_object_url(Some(1), blob, "https://example.test")
.unwrap();
let foreign_url = store
.create_object_url(Some(2), blob, "https://example.test")
.unwrap();
store.retain_blob_reader_ref(blob);
store.retire_owner_resources(1);
assert!(store.object_url_bytes_and_type(&own_url).is_none());
assert_eq!(store.blob_bytes(blob).unwrap(), b"retained");
store.release_blob_wrapper_ref(blob);
store.release_blob_reader_ref(blob);
assert_eq!(
store.object_url_bytes_and_type(&foreign_url).unwrap().0,
b"retained"
);
assert!(store.revoke_object_url(&foreign_url));
assert!(store.blob_bytes(blob).is_none());
}
#[test]
fn cleanup_owner_removes_owned_blobs_and_urls() {
let store = BlobStore::<u64, u64>::default();
+1 -1
View File
@@ -487,7 +487,7 @@ fn blob_mime_type(blob_id: BlobId) -> Option<String> {
}
pub(crate) fn cleanup_owner_resources(owner_id: ResourceOwnerId) {
blob_store().cleanup_owner_resources(owner_id);
blob_store().retire_owner_resources(owner_id);
}
fn release_blob_wrapper_ref(blob_id: BlobId) {
@@ -530,6 +530,16 @@ pub(crate) fn new_dom_exception_value<'s>(
scope,
"DOMException",
)
.or_else(|| {
// A native exception must materialize its own realm's intrinsic.
// Reading the public Window property can run author code or enter
// an access check again after navigation detaches its global.
crate::context_bootstrap::exposed_interfaces::ensure_intrinsic_interface_prototype(
scope,
"DOMException",
)
.ok()
})
{
let exception = v8::Object::new(scope);
initialize_dom_exception(scope, exception, message, name);
@@ -30,3 +30,5 @@ pub(super) use metadata::constructor_spec_is_eager;
#[cfg(test)]
pub(crate) use metadata::dedicated_worker_lazy_interface_names_for_test;
pub(in crate::context_bootstrap) use template_registry::ExposedInterfaceTemplateRegistry;
pub(crate) use realm_registry::retain_intrinsic_interfaces_in_realm;
@@ -20,9 +20,9 @@ pub(super) struct IntrinsicInterfaceRegistry {
}
pub(super) struct RealmInterfaceObjects {
constructor: v8::Global<v8::Object>,
prototype: v8::Global<v8::Object>,
public_interface: v8::Global<v8::Object>,
constructor: crate::util::RealmObjectHandle,
prototype: crate::util::RealmObjectHandle,
public_interface: crate::util::RealmObjectHandle,
}
impl IntrinsicInterfaceRegistry {
@@ -156,9 +156,9 @@ impl IntrinsicInterfaceRegistry {
RealmInterfaceEntry::Uninitialized | RealmInterfaceEntry::Materializing => {}
}
*slot = RealmInterfaceEntry::Ready(RealmInterfaceObjects {
constructor: v8::Global::new(scope, constructor),
prototype: v8::Global::new(scope, prototype),
public_interface: v8::Global::new(scope, public_interface),
constructor: crate::util::RealmObjectHandle::new(scope, constructor),
prototype: crate::util::RealmObjectHandle::new(scope, prototype),
public_interface: crate::util::RealmObjectHandle::new(scope, public_interface),
});
Ok(())
}
@@ -172,7 +172,7 @@ impl IntrinsicInterfaceRegistry {
let RealmInterfaceEntry::Ready(objects) = entries.get(id.index())? else {
return None;
};
Some(v8::Local::new(scope, &objects.constructor))
objects.constructor.to_local(scope)
}
pub(super) fn prototype<'s>(
@@ -184,7 +184,7 @@ impl IntrinsicInterfaceRegistry {
let RealmInterfaceEntry::Ready(objects) = entries.get(id.index())? else {
return None;
};
Some(v8::Local::new(scope, &objects.prototype))
objects.prototype.to_local(scope)
}
pub(super) fn public_interface<'s>(
@@ -196,10 +196,25 @@ impl IntrinsicInterfaceRegistry {
let RealmInterfaceEntry::Ready(objects) = entries.get(id.index())? else {
return None;
};
Some(v8::Local::new(scope, &objects.public_interface))
objects.public_interface.to_local(scope)
}
}
pub(crate) fn retain_intrinsic_interfaces_in_realm(scope: &mut v8::PinScope<'_, '_>) {
let context = scope.get_current_context();
let Some(registry) = context.get_slot::<IntrinsicInterfaceRegistry>() else {
return;
};
for entry in registry.entries.borrow_mut().iter_mut() {
if let RealmInterfaceEntry::Ready(objects) = entry {
objects.constructor.retain_in_realm(scope);
objects.prototype.retain_in_realm(scope);
objects.public_interface.retain_in_realm(scope);
}
}
crate::util::retain_context_v8_handle_state_for_safe_release(context, registry);
}
#[cfg(test)]
mod tests {
use std::pin::pin;
@@ -37,6 +37,19 @@ fn image_is_small_for_priority_boost(element: &crate::dom::native::Element) -> b
// the host document state accessors, current-script/parser visibility
// bookkeeping, and pending resource-load delivery that feed DCL/load decisions.
impl DocumentRuntime {
pub(crate) fn retire_v8_execution_state_for_context_teardown(&mut self) {
self.timeouts = HostTimeoutScheduler::default();
self.events = HostEventTargetRegistry::default();
self.script_lifecycle.clear_for_document_replacement();
self.root_document_parser = None;
self.document_write_script_preload_scanner = None;
self.document_write_script_preloads.clear();
self.pending_parser_blocking_work = None;
self.pending_parser_post_step_runtime_work = Default::default();
self.reset_main_parser_continuation_for_document_replacement();
self.main_document_script_preloads.clear();
}
pub(crate) fn bind_main_document_script_preload_store(
&mut self,
store: crate::runtime::DocumentScriptPreloadStore,
+120 -22
View File
@@ -23,6 +23,9 @@ pub(crate) use statics::{
};
const ABORT_SIGNAL_ID_SLOT: &str = "__lmAbortSignalId";
const ABORT_SIGNAL_STATE_SLOT: &str = "__moliAbortSignalState";
const ABORT_SIGNAL_ABORTED_SLOT: &str = "__moliAbortSignalAborted";
const ABORT_SIGNAL_REASON_SLOT: &str = "__moliAbortSignalReason";
const ABORT_CONTROLLER_ID_SLOT: &str = "__lmAbortControllerId";
const ABORT_CONTROLLER_SIGNAL_SLOT: &str = "__lmAbortControllerSignal";
@@ -35,6 +38,9 @@ pub(crate) fn bind_signal_wrapper<'s>(
return false;
};
set_private_value(scope, wrapper, ABORT_SIGNAL_ID_SLOT, id);
if let Some(state) = get_private_value(scope, signal, ABORT_SIGNAL_STATE_SLOT) {
set_private_value(scope, wrapper, ABORT_SIGNAL_STATE_SLOT, state);
}
true
}
@@ -44,13 +50,14 @@ pub(super) struct AbortStore {
next_controller_id: u32,
signals: HashMap<u32, AbortSignalState>,
controllers: HashMap<u32, u32>,
detached: bool,
}
#[derive(Default)]
struct AbortSignalState {
signal: Option<v8::Global<v8::Object>>,
signal: Option<crate::util::RealmObjectHandle>,
aborted: bool,
reason: Option<v8::Global<v8::Value>>,
detached: bool,
abort_algorithms: Vec<v8::Global<v8::Function>>,
linked_target_listeners: Vec<AbortLinkedTargetListener>,
// None for a source; Some (including empty) for a dependent signal's ordered roots.
@@ -68,9 +75,82 @@ struct AbortLinkedTargetListener {
struct AbortSignalDispatch {
algorithms: Vec<v8::Global<v8::Function>>,
linked_target_listeners: Vec<AbortLinkedTargetListener>,
dispatch_event: bool,
}
impl AbortStore {
pub(super) fn clear_for_context_teardown(&mut self) {
self.detached = true;
for state in self.signals.values_mut() {
state.detached = true;
state.abort_algorithms.clear();
state.linked_target_listeners.clear();
}
}
pub(crate) fn retire_context<'s>(
&mut self,
scope: &mut v8::PinScope<'s, '_>,
context: v8::Local<'s, v8::Context>,
) {
for state in self.signals.values_mut() {
let Some(handle) = state.signal.as_mut() else {
continue;
};
let Some(signal) = handle.to_local(scope) else {
continue;
};
if signal.get_creation_context(scope) != Some(context) {
continue;
}
handle.retain_in_realm(scope);
state.detached = true;
state.abort_algorithms.clear();
state.linked_target_listeners.clear();
}
}
fn signal_passive_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
signal: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
get_private_value(scope, signal, ABORT_SIGNAL_STATE_SLOT)
.and_then(|state| v8::Local::<v8::Object>::try_from(state).ok())
}
fn signal_aborted_from_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
signal: v8::Local<'s, v8::Object>,
) -> bool {
Self::signal_passive_state(scope, signal)
.and_then(|state| get_private_value(scope, state, ABORT_SIGNAL_ABORTED_SLOT))
.is_some_and(|value| value.is_true())
}
fn signal_reason_from_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
signal: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Value>> {
let state = Self::signal_passive_state(scope, signal)?;
get_private_value(scope, state, ABORT_SIGNAL_REASON_SLOT)
}
fn publish_signal_abort<'s>(
scope: &mut v8::PinScope<'s, '_>,
signal: v8::Local<'s, v8::Object>,
reason: v8::Local<'s, v8::Value>,
) {
if let Some(state) = Self::signal_passive_state(scope, signal) {
set_private_value(
scope,
state,
ABORT_SIGNAL_ABORTED_SLOT,
v8::Boolean::new(scope, true).into(),
);
set_private_value(scope, state, ABORT_SIGNAL_REASON_SLOT, reason);
}
}
fn alloc_signal_id(&mut self) -> u32 {
self.next_signal_id = self
.next_signal_id
@@ -102,9 +182,7 @@ impl AbortStore {
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> bool {
Self::signal_id_from_object(scope, object)
.and_then(|id| self.signal_state(id))
.is_some()
Self::signal_id_from_object(scope, object).is_some()
}
fn controller_id_from_object<'s>(
@@ -127,13 +205,25 @@ impl AbortStore {
let signal_id = self.alloc_signal_id();
let mut state = AbortSignalState {
aborted,
detached: self.detached,
..AbortSignalState::default()
};
state.signal = Some(v8::Global::new(scope, signal));
if let Some(reason) = reason {
state.reason = Some(v8::Global::new(scope, reason));
}
state.signal = Some(crate::util::RealmObjectHandle::new(scope, signal));
self.signals.insert(signal_id, state);
let passive_state = v8::Object::new(scope);
set_private_value(
scope,
passive_state,
ABORT_SIGNAL_ABORTED_SLOT,
v8::Boolean::new(scope, aborted).into(),
);
set_private_value(
scope,
passive_state,
ABORT_SIGNAL_REASON_SLOT,
reason.unwrap_or_else(|| v8::undefined(scope).into()),
);
set_private_value(scope, signal, ABORT_SIGNAL_STATE_SLOT, passive_state.into());
set_private_value(
scope,
signal,
@@ -182,7 +272,7 @@ impl AbortStore {
) -> Option<v8::Local<'s, v8::Object>> {
self.signal_state(id)
.and_then(|state| state.signal.as_ref())
.map(|signal| v8::Local::new(scope, signal))
.and_then(|signal| signal.to_local(scope))
}
pub(super) fn signal_aborted<'s>(
@@ -190,9 +280,7 @@ impl AbortStore {
scope: &mut v8::PinScope<'s, '_>,
signal: v8::Local<'s, v8::Object>,
) -> bool {
Self::signal_id_from_object(scope, signal)
.and_then(|id| self.signal_state(id))
.is_some_and(|state| state.aborted)
Self::signal_aborted_from_object(scope, signal)
}
pub(super) fn signal_reason<'s>(
@@ -200,10 +288,7 @@ impl AbortStore {
scope: &mut v8::PinScope<'s, '_>,
signal: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Value>> {
Self::signal_id_from_object(scope, signal)
.and_then(|id| self.signal_state(id))
.and_then(|state| state.reason.as_ref())
.map(|reason| v8::Local::new(scope, reason))
Self::signal_reason_from_object(scope, signal)
}
pub(crate) fn register_abort_algorithm<'s>(
@@ -218,6 +303,9 @@ impl AbortStore {
let Some(state) = self.signal_state_mut(signal_id) else {
return false;
};
if state.detached {
return false;
}
state
.abort_algorithms
.push(v8::Global::new(scope, algorithm));
@@ -283,12 +371,15 @@ impl AbortStore {
reason: v8::Local<'s, v8::Value>,
) -> Option<Vec<(u32, v8::Local<'s, v8::Object>)>> {
let signal_id = Self::signal_id_from_object(scope, signal)?;
if Self::signal_aborted_from_object(scope, signal) {
return None;
}
Self::publish_signal_abort(scope, signal, reason);
let state = self.signal_state_mut(signal_id)?;
if state.aborted {
return None;
}
state.aborted = true;
state.reason = Some(v8::Global::new(scope, reason));
let dependent_signals = state.dependent_signals.clone();
// Publish every reason before any author callback can reenter abort.
let mut signals = vec![(signal_id, signal)];
@@ -300,9 +391,13 @@ impl AbortStore {
continue;
}
state.aborted = true;
state.reason = Some(v8::Global::new(scope, reason));
if let Some(signal) = &state.signal {
signals.push((dependent_signal_id, v8::Local::new(scope, signal)));
if let Some(signal) = state
.signal
.as_ref()
.and_then(|signal| signal.to_local(scope))
{
Self::publish_signal_abort(scope, signal, reason);
signals.push((dependent_signal_id, signal));
}
}
Some(signals)
@@ -313,6 +408,7 @@ impl AbortStore {
Some(AbortSignalDispatch {
algorithms: std::mem::take(&mut state.abort_algorithms),
linked_target_listeners: std::mem::take(&mut state.linked_target_listeners),
dispatch_event: !state.detached,
})
}
@@ -384,7 +480,9 @@ pub(crate) fn abort_signal<'s>(
);
}
// Snapshot listeners after earlier signals and abort algorithms.
abort_signal_events::dispatch_abort(scope, signal);
if dispatch.dispatch_event {
abort_signal_events::dispatch_abort(scope, signal);
}
}
}
@@ -47,12 +47,12 @@ pub(crate) fn abort_controller_abort_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
let Some(_) = context_host_ptr_from_global_bridge(scope) else {
rv.set_undefined();
return;
};
let controller = args.this();
let Some(controller_id) = AbortStore::controller_id_from_object(scope, controller) else {
let Some(_) = AbortStore::controller_id_from_object(scope, controller) else {
rv.set_undefined();
return;
};
@@ -67,26 +67,6 @@ pub(crate) fn abort_controller_abort_callback<'s>(
} else {
abort_error_value(scope)
};
let host = unsafe { &mut *host_ptr };
let Some(signal_id) = host
.native_bridge_mut()
.abort
.controllers
.get(&controller_id)
.copied()
else {
rv.set_undefined();
return;
};
if host
.native_bridge_mut()
.abort
.signal_state(signal_id)
.is_some_and(|state| state.aborted)
{
rv.set_undefined();
return;
}
crate::native_bridge::abort::abort_signal(scope, signal, reason);
rv.set_undefined();
}
@@ -1,31 +1,11 @@
use super::AbortStore;
use crate::util::context_host_ptr_from_global_bridge;
pub(crate) fn abort_signal_aborted_getter_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
rv.set_bool(false);
return;
};
let signal = args.this();
if AbortStore::signal_id_from_object(scope, signal).is_none() {
rv.set_bool(false);
return;
}
// SAFETY: as_ptr() — this getter may be called during event dispatch
// (re-entrant from another callback holding borrow_mut). See util.rs.
let aborted = AbortStore::signal_id_from_object(scope, signal)
.and_then(|id| {
unsafe { &mut *host_ptr }
.native_bridge_mut()
.abort
.signal_state(id)
})
.is_some_and(|state| state.aborted);
rv.set_bool(aborted);
rv.set_bool(AbortStore::signal_aborted_from_object(scope, args.this()));
}
pub(crate) fn abort_signal_reason_getter_callback<'s>(
@@ -33,29 +13,10 @@ pub(crate) fn abort_signal_reason_getter_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
rv.set_undefined();
return;
};
let signal = args.this();
if AbortStore::signal_id_from_object(scope, signal).is_none() {
rv.set_undefined();
return;
match AbortStore::signal_reason_from_object(scope, args.this()) {
Some(reason) => rv.set(reason),
None => rv.set_undefined(),
}
let Some(reason) = AbortStore::signal_id_from_object(scope, signal)
.and_then(|id| {
unsafe { &mut *host_ptr }
.native_bridge_mut()
.abort
.signal_state(id)
})
.and_then(|state| state.reason.as_ref())
.map(|reason| v8::Local::new(scope, reason))
else {
rv.set_undefined();
return;
};
rv.set(reason);
}
pub(crate) fn abort_signal_throw_if_aborted_callback<'s>(
@@ -63,30 +24,12 @@ pub(crate) fn abort_signal_throw_if_aborted_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
rv.set_undefined();
return;
};
let signal = args.this();
if AbortStore::signal_id_from_object(scope, signal).is_none() {
if !AbortStore::signal_aborted_from_object(scope, signal) {
rv.set_undefined();
return;
}
let Some(reason) = AbortStore::signal_id_from_object(scope, signal)
.and_then(|id| {
unsafe { &mut *host_ptr }
.native_bridge_mut()
.abort
.signal_state(id)
})
.filter(|state| state.aborted)
.and_then(|state| state.reason.as_ref())
.map(|reason| v8::Local::new(scope, reason))
else {
rv.set_undefined();
return;
};
// DOM requires throwing the stored abort reason itself. In particular, a
// string reason remains a string rather than being wrapped in `Error`.
let reason = AbortStore::signal_reason_from_object(scope, signal)
.unwrap_or_else(|| v8::undefined(scope).into());
scope.throw_exception(reason);
}
@@ -91,20 +91,11 @@ pub(crate) fn abort_signal_any_callback<'s>(
let signals = parsed.signals;
for source_signal in &signals {
let Some(source_signal_id) = AbortStore::signal_id_from_object(scope, *source_signal)
else {
if !AbortStore::signal_aborted_from_object(scope, *source_signal) {
continue;
};
let Some(reason) = host
.native_bridge_mut()
.abort
.signal_state(source_signal_id)
.filter(|state| state.aborted)
.and_then(|state| state.reason.as_ref())
.map(|reason| v8::Local::new(scope, reason))
else {
continue;
};
}
let reason = AbortStore::signal_reason_from_object(scope, *source_signal)
.unwrap_or_else(|| v8::undefined(scope).into());
crate::native_bridge::abort::abort_signal(scope, signal, reason);
rv.set(signal.into());
return;
@@ -81,7 +81,11 @@ impl JsContextHost {
// The actual JsContextHost pointer for runtime_ptr_from_object compatibility.
let bridge = unsafe { &mut (*host_ptr).bridge };
bridge.install_global(scope, global, host_ptr, bridge_ref.as_external_ptr())?;
crate::util::install_context_host_pointer_slot(scope.get_current_context(), host_ptr);
crate::util::install_context_host_pointer_slot(
scope.get_current_context(),
host_ptr,
unsafe { &*host_ptr }.context_host_lifecycle_handle(),
);
Ok(bridge_ref)
}
}
@@ -59,6 +59,26 @@ impl JsContextHost {
closed_count
}
pub(crate) fn close_broadcast_channels_for_context_token(
&mut self,
context_token: RuntimeObservableContextToken,
) -> usize {
let channel_ids = self
.broadcast_channel_wrappers
.iter()
.filter_map(|(channel_id, entry)| {
(entry.identity.realm_token() == context_token).then_some(*channel_id)
})
.collect::<Vec<_>>();
let closed_count = channel_ids.len();
for channel_id in channel_ids {
self.broadcast_channel_wrappers.remove(&channel_id);
self.broadcast_channel_registry
.close_broadcast_channel(channel_id);
}
closed_count
}
pub(crate) fn register_broadcast_channel_wrapper(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
@@ -962,7 +962,11 @@ impl JsContextHost {
else {
return false;
};
crate::util::install_context_host_pointer_slot(context, self as *mut Self);
crate::util::install_context_host_pointer_slot(
context,
self as *mut Self,
self.context_host_lifecycle_handle(),
);
let previous = context.set_slot(Rc::new(ChildWindowProxyFacadeContextHandle(handle)));
debug_assert!(previous.is_none());
// Keep V8's unique default security token. A facade is not a real
@@ -212,6 +212,10 @@ impl JsContextHost {
root_document_lifecycle: None,
output_journal: None,
page_context_resources_closed: false,
context_host_lifecycle: Rc::new(Cell::new(crate::util::ContextHostLifecycle::Active)),
deferred_context_host_release_queue: Default::default(),
retained_document_runtime: None,
published_document_host: false,
page_default_context: None,
v8_finalizers: crate::v8_finalizer::V8FinalizerRegistry::default(),
bridge: NativeDomBridge::new(bindings),
@@ -820,6 +820,8 @@ pub(crate) struct JsContextHost {
root_document_lifecycle: Option<RendererDocumentLifecycleJournalHandle>,
output_journal: Option<crate::runtime::RendererTurnOutputJournal>,
page_context_resources_closed: bool,
context_host_lifecycle: Rc<Cell<crate::util::ContextHostLifecycle>>,
deferred_context_host_release_queue: crate::script_vm::RendererDeferredContextHostReleaseQueue,
page_default_context: Option<v8::Weak<v8::Context>>,
pub(crate) v8_finalizers: crate::v8_finalizer::V8FinalizerRegistry,
pub(super) bridge: NativeDomBridge,
@@ -1099,6 +1101,8 @@ pub(crate) struct JsContextHost {
stylo_computed_style_property_reads: Cell<u64>,
#[cfg(test)]
style_observation_environment_resolutions: Cell<u64>,
retained_document_runtime: Option<Box<DocumentRuntime>>,
published_document_host: bool,
}
struct PendingTextControlChangeCommit {
@@ -1253,16 +1257,66 @@ impl JsContextHost {
self.app_manifest_link_change_epoch
}
pub(crate) fn document_host_is_published(&self) -> bool {
self.published_document_host
}
pub(crate) fn publish_document_host(&mut self) {
self.published_document_host = true;
}
pub(crate) fn mark_page_context_detached(&self) {
if self.context_host_lifecycle.get() == crate::util::ContextHostLifecycle::Active {
self.context_host_lifecycle
.set(crate::util::ContextHostLifecycle::Detached);
}
}
pub(crate) fn context_host_lifecycle_handle(
&self,
) -> Rc<Cell<crate::util::ContextHostLifecycle>> {
self.context_host_lifecycle.clone()
}
pub(crate) fn deferred_context_host_release_queue(
&self,
) -> crate::script_vm::RendererDeferredContextHostReleaseQueue {
self.deferred_context_host_release_queue.clone()
}
pub(crate) fn adopt_retained_document_runtime(
&mut self,
document_runtime: Box<DocumentRuntime>,
) {
assert!(
self.retained_document_runtime.is_none(),
"JsContextHost must adopt its DocumentRuntime at most once"
);
assert!(
std::ptr::eq(self.runtime.cast_const(), document_runtime.as_ref()),
"retained DocumentRuntime transfer must preserve the host raw pointer"
);
self.retained_document_runtime = Some(document_runtime);
}
pub(crate) fn bind_deferred_context_host_release_queue(
&mut self,
queue: crate::script_vm::RendererDeferredContextHostReleaseQueue,
) {
self.deferred_context_host_release_queue = queue;
}
pub(crate) fn close_page_context_resources_for_teardown(&mut self) {
self.mark_page_context_detached();
if self.page_context_resources_closed {
return;
}
self.page_context_resources_closed = true;
self.bridge.web_mcp.clear();
self.retire_all_window_execution_context_resources_for_teardown();
self.pending_history_traversal_admissions.clear();
self.retire_all_document_resource_loaders();
self.page_default_context = None;
self.v8_finalizers.clear_for_context_teardown();
self.clear_pending_top_level_navigation();
self.unregister_all_service_worker_child_clients();
self.unregister_all_service_worker_popup_clients();
@@ -1272,11 +1326,20 @@ impl JsContextHost {
self.close_owned_broadcast_channels();
self.close_owned_message_ports();
self.shutdown_workers();
self.output_journal = None;
self.root_document_lifecycle = None;
self.child_default_context_bootstrap = None;
self.command_turn_output = None;
self.lightweight_popup_browsing_contexts.clear();
self.internal_inspector_value_references.clear();
}
}
impl Drop for JsContextHost {
fn drop(&mut self) {
self.close_page_context_resources_for_teardown();
self.context_host_lifecycle
.set(crate::util::ContextHostLifecycle::Destroyed);
self.v8_finalizers.clear_for_context_teardown();
}
}
@@ -100,6 +100,103 @@ impl PendingRuntimeObservableConsoleSourceEvent {
}
impl JsContextHost {
pub(crate) fn retire_all_window_execution_context_resources_for_teardown(&mut self) {
let owners = self
.window_execution_contexts
.keys()
.copied()
.collect::<Vec<_>>();
let mut realm_tokens = self
.window_execution_contexts
.values()
.map(WindowExecutionContextBinding::realm_token)
.chain(
self.window_execution_context_realms
.concrete_by_token
.keys()
.copied(),
)
.collect::<Vec<_>>();
realm_tokens.sort_unstable();
realm_tokens.dedup();
for realm_token in realm_tokens {
self.cancel_timers_for_context_token(realm_token);
self.retire_runtime_binding_context_token(realm_token);
self.retire_image_decode_requests_for_context_token(realm_token);
self.retire_webcrypto_context_token(realm_token);
self.retire_opfs_context_token(realm_token);
self.retire_workers_for_context_token(realm_token);
self.disconnect_shared_worker_clients_for_context_token(realm_token);
self.retire_window_xhrs_for_context_token(realm_token);
self.retire_window_fetches_for_context_token(realm_token);
self.retire_window_event_sources_for_context_token(realm_token);
self.retire_message_ports_for_context_token(realm_token);
self.retire_window_messages_for_context_token(realm_token);
self.close_broadcast_channels_for_context_token(realm_token);
self.retire_websockets_for_context_token(realm_token);
self.retire_window_execution_contexts_for_context_token(realm_token);
}
for owner in owners {
self.cancel_window_execution_context_timers(owner);
self.retire_webcrypto_execution_context_owner(owner);
self.retire_opfs_execution_context_owner(owner);
self.retire_workers_for_execution_context_owner(owner);
self.disconnect_shared_worker_clients_for_execution_context_owner(owner);
self.retire_window_xhrs_for_execution_context_owner(owner);
self.retire_window_fetches_for_execution_context_owner(owner);
self.retire_window_event_sources_for_execution_context_owner(owner);
self.retire_window_messages_for_execution_context_owner(owner);
self.close_broadcast_channels_for_execution_context_owner(owner);
self.retire_websockets_for_execution_context_owner(owner);
self.retire_image_decode_requests_for_execution_context_owner(owner);
self.retire_message_ports_for_execution_context_owner(owner);
self.retire_window_execution_context(owner);
}
self.retire_v8_execution_state_for_context_teardown();
// A detached Document realm may keep this host and its native DOM
// alive through the V8 Context slot. None of the host's active
// execution registries may in turn keep that Context alive with a
// strong Global handle: that would form an untraceable
// Context -> Rust host -> Global -> Context cycle. Chromium retires
// these LocalDOMWindow/ExecutionContext-owned services when the frame
// is detached while ordinary retained Document/Node values remain
// usable.
drop(std::mem::take(&mut self.custom_elements));
self.child_custom_elements.clear();
self.scoped_custom_elements.clear();
drop(std::mem::take(&mut self.custom_element_reactions));
drop(std::mem::take(&mut self.observers));
drop(std::mem::take(&mut self.child_window_proxy_records));
self.pending_service_worker_registers.clear();
self.pending_service_worker_unregisters.clear();
self.pending_service_worker_ready.clear();
self.service_worker_registration_watchers.clear();
self.pending_window_messages.clear();
drop(std::mem::take(&mut self.directory_reader_callbacks));
drop(std::mem::take(&mut self.misc_platform_api_tasks));
drop(std::mem::take(&mut self.file_entry_file_callbacks));
drop(std::mem::take(&mut self.user_interaction_tasks));
self.pending_image_load_events.clear();
self.pending_media_load_sequences.clear();
self.pending_text_track_load_sequences.clear();
self.pending_media_text_track_gates.clear();
self.resource_timing_buffers =
super::resource_timing::SharedResourceTimingBufferRegistry::new();
drop(std::mem::take(&mut self.history_queue));
drop(std::mem::take(&mut self.rendering_updates));
drop(std::mem::take(&mut self.view_transition_updates));
drop(std::mem::take(&mut self.media_element_events));
drop(std::mem::take(&mut self.element_toggle_events));
drop(std::mem::take(&mut self.text_track_default_modes));
self.child_window_event_listeners.clear();
drop(std::mem::take(&mut self.event_callbacks));
self.bridge.abort.clear_for_context_teardown();
}
pub(crate) fn register_lightweight_popup_execution_context(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
@@ -127,6 +127,24 @@ impl JsContextHost {
retired
}
pub(crate) fn retire_websockets_for_context_token(
&mut self,
context_token: super::super::RuntimeObservableContextToken,
) -> usize {
let socket_ids = self
.websockets
.iter()
.filter_map(|(socket_id, state)| {
(state.owner.realm_token() == context_token).then_some(*socket_id)
})
.collect::<Vec<_>>();
let retired = socket_ids.len();
for socket_id in socket_ids {
self.retire_websocket(socket_id);
}
retired
}
pub(crate) fn retire_websocket(&mut self, socket_id: u64) -> bool {
let Some(state) = self.websockets.remove(&socket_id) else {
return false;
+57 -20
View File
@@ -448,14 +448,14 @@ pub(crate) fn contexts_share_wrapper_world(
#[derive(Debug)]
struct BridgeCachedWrapper {
wrapper: v8::Global<v8::Object>,
wrapper: crate::util::RealmObjectHandle,
creation_realm: Option<NonZeroU64>,
}
impl BridgeCachedWrapper {
fn new(scope: &mut v8::PinScope<'_, '_>, wrapper: v8::Local<'_, v8::Object>) -> Self {
Self {
wrapper: v8::Global::new(scope, wrapper),
wrapper: crate::util::RealmObjectHandle::new(scope, wrapper),
creation_realm: scope
.get_current_context()
.get_slot::<RuntimeObservableContextToken>()
@@ -468,7 +468,7 @@ impl BridgeCachedWrapper {
#[derive(Debug)]
struct BridgeContextWindowWrapper {
wrapper: v8::Global<v8::Object>,
wrapper: RefCell<crate::util::RealmObjectHandle>,
}
fn context_window_wrapper<'s>(
@@ -477,7 +477,7 @@ fn context_window_wrapper<'s>(
scope
.get_current_context()
.get_slot::<BridgeContextWindowWrapper>()
.map(|entry| v8::Local::new(scope, &entry.wrapper))
.and_then(|entry| entry.wrapper.borrow().to_local(scope))
}
fn set_context_window_wrapper(
@@ -487,7 +487,7 @@ fn set_context_window_wrapper(
let _ = scope
.get_current_context()
.set_slot(Rc::new(BridgeContextWindowWrapper {
wrapper: v8::Global::new(scope, wrapper),
wrapper: RefCell::new(crate::util::RealmObjectHandle::new(scope, wrapper)),
}));
}
@@ -514,7 +514,16 @@ impl BridgeContextWrapperCacheRetainForTest {
self.cache.borrow().wrappers.len()
}
pub(crate) fn wrapper_entry_count_for_realm(
pub(crate) fn strong_wrapper_entry_count(&self) -> usize {
self.cache
.borrow()
.wrappers
.values()
.filter(|entry| entry.wrapper.is_strong())
.count()
}
pub(crate) fn strong_wrapper_entry_count_for_realm(
&self,
realm_token: RuntimeObservableContextToken,
) -> usize {
@@ -522,7 +531,10 @@ impl BridgeContextWrapperCacheRetainForTest {
.borrow()
.wrappers
.values()
.filter(|entry| entry.creation_realm.map(NonZeroU64::get) == Some(realm_token.as_u64()))
.filter(|entry| {
entry.wrapper.is_strong()
&& entry.creation_realm.map(NonZeroU64::get) == Some(realm_token.as_u64())
})
.count()
}
}
@@ -541,18 +553,41 @@ pub(crate) fn clear_context_wrapper_cache_for_teardown(
include_shared_default_world: bool,
) {
let context = scope.get_current_context();
if !include_shared_default_world
&& context
crate::util::detach_document_page_context(context);
if let Some(host) = crate::util::context_host_ptr_from_context_slot(context) {
unsafe { &mut *host }
.native_bridge_mut()
.abort
.retire_context(scope, context);
}
let all_entries = include_shared_default_world
|| context
.get_slot::<SharedDefaultWorldWrapperCache>()
.is_some()
{
return;
}
.is_none();
let realm = context
.get_slot::<RuntimeObservableContextToken>()
.as_deref()
.map(|token| token.as_u64());
if let Some(cache) = context.get_slot::<RefCell<BridgeContextWrapperCache>>() {
let mut cache = cache.borrow_mut();
cache.wrappers.clear();
cache.live_collection_wrappers.clear();
let mut entries = cache.borrow_mut();
for entry in entries.wrappers.values_mut() {
if all_entries || entry.creation_realm.map(NonZeroU64::get) == realm {
entry.wrapper.retain_in_realm(scope);
}
}
for entry in entries.live_collection_wrappers.values_mut() {
if all_entries || entry.creation_realm.map(NonZeroU64::get) == realm {
entry.wrapper.retain_in_realm(scope);
}
}
drop(entries);
crate::util::retain_context_v8_handle_state_for_safe_release(context, cache);
}
if let Some(wrapper) = context.get_slot::<BridgeContextWindowWrapper>() {
wrapper.wrapper.borrow_mut().retain_in_realm(scope);
crate::util::retain_context_v8_handle_state_for_safe_release(context, wrapper);
}
crate::context_bootstrap::exposed_interfaces::retain_intrinsic_interfaces_in_realm(scope);
}
#[derive(Debug, Default)]
@@ -603,7 +638,7 @@ impl BridgeIdentityStore {
.borrow()
.wrappers
.get(&reflector_id)
.map(|entry| v8::Local::new(scope, &entry.wrapper))
.and_then(|entry| entry.wrapper.to_local(scope))
}
pub(super) fn cache_wrapper(
@@ -642,7 +677,7 @@ impl BridgeIdentityStore {
.borrow()
.live_collection_wrappers
.get(descriptor)
.map(|entry| v8::Local::new(scope, &entry.wrapper))
.and_then(|entry| entry.wrapper.to_local(scope))
}
pub(super) fn cache_live_collection_wrapper(
@@ -664,9 +699,11 @@ impl BridgeIdentityStore {
let mut cache = self.default_world_wrapper_cache.borrow_mut();
cache.wrappers.retain(|_, entry| {
entry.creation_realm.map(NonZeroU64::get) != Some(realm_token.as_u64())
|| !entry.wrapper.is_strong()
});
cache.live_collection_wrappers.retain(|_, entry| {
entry.creation_realm.map(NonZeroU64::get) != Some(realm_token.as_u64())
|| !entry.wrapper.is_strong()
});
}
@@ -749,9 +786,9 @@ mod tests {
}
#[test]
fn cached_wrapper_entry_uses_the_global_handle_niche() {
fn cached_wrapper_entry_bounds_the_handle_and_realm_metadata() {
#[cfg(target_pointer_width = "64")]
assert_eq!(size_of::<BridgeCachedWrapper>(), 24);
assert_eq!(size_of::<BridgeCachedWrapper>(), 32);
}
#[test]
@@ -62,6 +62,7 @@ impl<V> DenseReflectorMap<V> {
self.sparse.insert(id, value)
}
#[cfg(test)]
pub(super) fn clear(&mut self) {
self.dense_base = None;
self.dense.clear();
@@ -112,6 +113,13 @@ impl<V> DenseReflectorMap<V> {
pub(super) fn values(&self) -> impl Iterator<Item = &V> {
self.dense.iter().flatten().chain(self.sparse.values())
}
pub(super) fn values_mut(&mut self) -> impl Iterator<Item = &mut V> {
self.dense
.iter_mut()
.flatten()
.chain(self.sparse.values_mut())
}
}
#[cfg(test)]
@@ -1,3 +1,4 @@
use crate::v8_traced_webidl_callback::V8TracedWebIdlCallbackInterface;
use crate::webidl::{PreparedWebIdlCallbackInterface, WebIdlCallbackInterface};
use crate::{
callback_invocation::{
@@ -28,37 +29,44 @@ pub(super) enum TraversalFilterResult {
/// The callback-interface value owned by one TreeWalker or NodeIterator.
///
/// Traversal owns registration lifetime and reentrancy. The callback kernel
/// owns the rooted callback plus its relevant/incumbent contexts. The renderer
/// The wrapper owns a V8-traced callback carrier; the native registry only
/// observes it weakly. The callback kernel roots invocation snapshots. The renderer
/// identity is deliberately kept beside those engine-neutral facts so a
/// detached or replaced Window cannot re-enter through a still-reachable
/// traversal wrapper.
pub(in crate::native_bridge) struct TraversalFilter {
callback: WebIdlCallbackInterface,
callback: v8::Weak<v8::Object>,
execution_context: Option<WindowExecutionContextIdentity>,
}
impl TraversalFilter {
pub(super) fn new(
scope: &mut v8::PinScope<'_, '_>,
pub(super) fn new<'s>(
scope: &mut v8::PinScope<'s, '_>,
runtime_ptr: *mut JsContextHost,
wrapper: v8::Local<'s, v8::Object>,
callback: WebIdlCallbackInterface,
) -> Self {
let prepared = callback.prepare(scope);
let relevant_context = prepared.relevant_context(scope);
let execution_context = unsafe { &*runtime_ptr }
.window_execution_context_identity_for_v8_context(scope, relevant_context);
let carrier = V8TracedWebIdlCallbackInterface::new(scope, callback).into_object();
crate::util::set_private_value(scope, wrapper, "__lmTraversalFilter", carrier.into());
Self {
callback,
callback: v8::Weak::new(scope, carrier),
execution_context,
}
}
pub(super) fn prepare(&self, scope: &mut v8::PinScope<'_, '_>) -> PreparedTraversalFilter {
PreparedTraversalFilter {
callback: self.callback.prepare(scope),
pub(super) fn prepare(
&self,
scope: &mut v8::PinScope<'_, '_>,
) -> Option<PreparedTraversalFilter> {
let carrier = self.callback.to_local(scope)?;
Some(PreparedTraversalFilter {
callback: V8TracedWebIdlCallbackInterface::from_object(carrier).prepare(scope),
execution_context: self.execution_context,
}
})
}
}
@@ -219,7 +219,7 @@ fn prepare_filter(
filter: Option<&TraversalFilter>,
) -> Option<Rc<PreparedTraversalFilter>> {
let filter = filter?;
Some(Rc::new(filter.prepare(scope)))
Some(Rc::new(filter.prepare(scope)?))
}
#[cfg(test)]
@@ -115,12 +115,12 @@ pub(in crate::native_bridge) fn build_node_iterator_wrapper<'s>(
what_to_show: u32,
filter: Option<WebIdlCallbackInterface>,
) -> v8::Local<'s, v8::Object> {
let filter = filter.map(|filter| TraversalFilter::new(scope, runtime_ptr, filter));
let id = bridge.register_node_iterator(root, what_to_show, filter);
let template = bridge.node_iterator_wrapper_template();
let wrapper = template
.new_instance(scope)
.expect("failed to instantiate NodeIterator wrapper");
let filter = filter.map(|filter| TraversalFilter::new(scope, runtime_ptr, wrapper, filter));
let id = bridge.register_node_iterator(root, what_to_show, filter);
let runtime_external = v8::External::new(scope, runtime_ptr as *mut c_void);
assert!(
wrapper.set_internal_field(0, runtime_external.into()),
@@ -142,12 +142,12 @@ pub(in crate::native_bridge) fn build_tree_walker_wrapper<'s>(
what_to_show: u32,
filter: Option<WebIdlCallbackInterface>,
) -> v8::Local<'s, v8::Object> {
let filter = filter.map(|filter| TraversalFilter::new(scope, runtime_ptr, filter));
let id = bridge.register_tree_walker(root, what_to_show, filter);
let template = bridge.tree_walker_wrapper_template();
let wrapper = template
.new_instance(scope)
.expect("failed to instantiate TreeWalker wrapper");
let filter = filter.map(|filter| TraversalFilter::new(scope, runtime_ptr, wrapper, filter));
let id = bridge.register_tree_walker(root, what_to_show, filter);
let runtime_external = v8::External::new(scope, runtime_ptr as *mut c_void);
assert!(
wrapper.set_internal_field(0, runtime_external.into()),
+69 -8
View File
@@ -891,9 +891,10 @@ pub(crate) use standalone_test_harness::StandaloneScriptVmHarness;
use crate::document_runtime::{DeferredPageTaskLane, FollowupPageTaskDisposition};
use document_isolate::*;
pub(crate) use document_isolate::{
RendererDocumentIsolateBootstrap, RendererDocumentIsolateHandle,
RendererDocumentIsolateReservationAccounting, RendererPageScriptEnvironment,
ScriptVmDefaultWorldBootstrap, renderer_document_isolate_accounting_diagnostics,
RendererDeferredContextHostReleaseQueue, RendererDocumentIsolateBootstrap,
RendererDocumentIsolateHandle, RendererDocumentIsolateReservationAccounting,
RendererPageScriptEnvironment, ScriptVmDefaultWorldBootstrap,
renderer_document_isolate_accounting_diagnostics,
};
pub(crate) use eval_exec::execute_source_text_on_current_stack;
pub(crate) use input_helpers::*;
@@ -907,6 +908,7 @@ pub(crate) use runtime_bindings::PromiseRejectDispatchSlot;
pub(crate) use runtime_bindings::perform_microtask_checkpoint_and_report_pending_promise_rejections;
use runtime_bindings::*;
pub(crate) use runtime_work::*;
use std::ops::{Deref, DerefMut};
#[cfg(any(test, feature = "test-support"))]
type ScriptGlobalsBaseline = Vec<String>;
@@ -950,15 +952,46 @@ fn register_main_window_execution_context_for_bootstrap(
.context("failed to register main LocalWindow execution context")
}
pub(super) struct ScriptVmDocumentRuntimeOwner {
document_runtime: Option<Box<DocumentRuntime>>,
}
impl ScriptVmDocumentRuntimeOwner {
fn new(document_runtime: Box<DocumentRuntime>) -> Self {
Self {
document_runtime: Some(document_runtime),
}
}
fn take_for_retained_document_host(&mut self) -> Option<Box<DocumentRuntime>> {
self.document_runtime.take()
}
}
impl Deref for ScriptVmDocumentRuntimeOwner {
type Target = DocumentRuntime;
fn deref(&self) -> &Self::Target {
self.document_runtime
.as_deref()
.expect("ScriptVm DocumentRuntime must remain owned until ScriptVm drop")
}
}
impl DerefMut for ScriptVmDocumentRuntimeOwner {
fn deref_mut(&mut self) -> &mut Self::Target {
self.document_runtime
.as_deref_mut()
.expect("ScriptVm DocumentRuntime must remain owned until ScriptVm drop")
}
}
pub(super) struct ScriptVm {
resource_owner_id: crate::resource_owner::ResourceOwnerId,
/// Page/target-facing inspector state. This must drop before the renderer
/// document isolate handle because the V8 inspector session touches the
/// isolate-level backend while being destroyed.
page_inspector: DocumentInspectorBinding,
/// Handle to renderer-owner document isolate-level V8 state. Multiple page
/// facades can share the holder while keeping page/context state here.
renderer_document_isolate: RendererDocumentIsolateHandle,
renderer_document_isolate_teardown: RendererDocumentIsolateTeardown,
renderer_page_script_environment: Option<RendererPageScriptEnvironment>,
page_default_context: v8::Global<v8::Context>,
@@ -973,7 +1006,7 @@ pub(super) struct ScriptVm {
// `JsContextHost` stores a non-owning pointer into `document_runtime`, so it
// must be dropped before the runtime field during normal Rust field teardown.
_context_host: Rc<RefCell<JsContextHost>>,
pub(super) document_runtime: Box<DocumentRuntime>,
pub(super) document_runtime: ScriptVmDocumentRuntimeOwner,
post_domcontentloaded_page_task_tx: PageTaskSender,
page_runtime_wake_tx: PageRuntimeWakeSender,
queued_main_document_runtime_continuation_owner:
@@ -1014,6 +1047,9 @@ pub(super) struct ScriptVm {
#[cfg(test)]
_page_task_residence_for_executor_test:
Option<crate::page_task_queue::RendererPageTaskTestResidence>,
// Native and V8 per-document state must drop before the final isolate
// handle; retained realms then release their host through its GC queue.
renderer_document_isolate: RendererDocumentIsolateHandle,
}
impl moli_layout::GeometryProvider for ScriptVm {
@@ -2010,6 +2046,11 @@ impl ScriptVmPageRealmBootstrap {
top_level_storage_key,
reserved_service_worker_client_id,
)));
context_host
.borrow_mut()
.bind_deferred_context_host_release_queue(
renderer_document_isolate.deferred_context_host_release_queue(),
);
let main_document_owner = context_host
.borrow()
.current_main_document_task_owner()
@@ -2383,7 +2424,7 @@ impl ScriptVmDefaultWorldBootstrap {
page_default_runtime_observable_context_token: runtime_observable_context_token,
root_frame_id,
baseline_globals,
document_runtime,
document_runtime: ScriptVmDocumentRuntimeOwner::new(document_runtime),
_context_host: context_host,
page_context_cancel_tx,
post_domcontentloaded_page_task_tx,
@@ -2426,6 +2467,26 @@ impl ScriptVmDefaultWorldBootstrap {
.borrow_mut()
.bind_output_journal(environment.output_journal());
}
vm._context_host.borrow_mut().publish_document_host();
vm.renderer_document_isolate
.with_renderer_document_isolate_mut(|isolate| {
let scope = pin!(v8::HandleScope::new(isolate));
let scope = &mut scope.init();
crate::util::retain_context_host_for_document_realm(
v8::Local::new(scope, &vm.page_default_context),
vm._context_host.clone(),
vm.renderer_document_isolate
.deferred_context_host_release_queue(),
);
for child in vm.prebootstrapped_child_default_contexts.borrow().values() {
crate::util::retain_context_host_for_document_realm(
v8::Local::new(scope, &child.context),
vm._context_host.clone(),
vm.renderer_document_isolate
.deferred_context_host_release_queue(),
);
}
});
Ok(vm)
}
}
@@ -34,7 +34,7 @@ use crate::{
};
use anyhow::{Result, anyhow};
use std::{
cell::{OnceCell, RefCell},
cell::{Cell, OnceCell, RefCell},
rc::Rc,
sync::atomic::{AtomicU64, Ordering},
};
@@ -44,6 +44,67 @@ static DOCUMENT_ISOLATE_DESTROYED_COUNT: AtomicU64 = AtomicU64::new(0);
static DOCUMENT_ISOLATE_LIVE_COUNT: AtomicU64 = AtomicU64::new(0);
static DOCUMENT_ISOLATE_RESERVED_COUNT: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, Default)]
pub(crate) struct RendererDeferredContextHostReleaseQueue {
inner: Rc<RendererDeferredContextHostReleaseQueueInner>,
}
struct RendererDeferredContextHostRelease {
_host: Rc<RefCell<JsContextHost>>,
retained_v8_handle_state: Vec<Box<dyn std::any::Any>>,
}
impl std::fmt::Debug for RendererDeferredContextHostRelease {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RendererDeferredContextHostRelease")
.field(
"retained_v8_handle_state_count",
&self.retained_v8_handle_state.len(),
)
.finish_non_exhaustive()
}
}
#[derive(Debug, Default)]
struct RendererDeferredContextHostReleaseQueueInner {
pending: RefCell<Vec<RendererDeferredContextHostRelease>>,
isolate_shutting_down: Cell<bool>,
}
impl RendererDeferredContextHostReleaseQueue {
pub(crate) fn defer(
&self,
host: Rc<RefCell<JsContextHost>>,
retained_v8_handle_state: Vec<Box<dyn std::any::Any>>,
) {
let release = RendererDeferredContextHostRelease {
_host: host,
retained_v8_handle_state,
};
if self.inner.isolate_shutting_down.get() {
drop(release);
return;
}
self.inner.pending.borrow_mut().push(release);
}
fn drain_on_entered_isolate(&self) {
loop {
let pending = std::mem::take(&mut *self.inner.pending.borrow_mut());
if pending.is_empty() {
return;
}
drop(pending);
}
}
fn begin_isolate_shutdown(&self) {
self.drain_on_entered_isolate();
self.inner.isolate_shutting_down.set(true);
}
}
pub(crate) fn renderer_document_isolate_accounting_diagnostics()
-> crate::runtime::RendererDocumentIsolateAccountingDiagnostics {
crate::runtime::RendererDocumentIsolateAccountingDiagnostics {
@@ -93,11 +154,8 @@ pub(super) struct ScriptVmPageRealmBootstrap {
pub(super) resource_owner_id: ResourceOwnerId,
pub(super) promise_reject_dispatch: PromiseRejectDispatchSlot,
pub(super) page_inspector: DocumentInspectorBinding,
pub(super) renderer_document_isolate: RendererDocumentIsolateHandle,
pub(super) renderer_document_isolate_teardown: RendererDocumentIsolateTeardown,
pub(super) document_runtime: Box<DocumentRuntime>,
pub(super) root_frame_id: Option<String>,
pub(super) context_host: Rc<RefCell<JsContextHost>>,
pub(super) prebootstrapped_child_default_contexts: SharedPrebootstrappedChildDefaultContexts,
pub(super) page_context_cancel_tx: RendererPageContextCancelSender,
pub(super) post_domcontentloaded_page_task_tx: PageTaskSender,
@@ -105,6 +163,12 @@ pub(super) struct ScriptVmPageRealmBootstrap {
pub(super) storage_bucket_store: crate::context_bootstrap::SharedStorageBucketStore,
pub(super) renderer_page_script_environment: Option<RendererPageScriptEnvironment>,
pub(super) reuse_main_window_proxy: bool,
// Partial bootstrap can drop before publication. Release bridge owners
// before their native backing, and keep V8 alive until all of it
// has been released.
pub(super) context_host: Rc<RefCell<JsContextHost>>,
pub(super) document_runtime: Box<DocumentRuntime>,
pub(super) renderer_document_isolate: RendererDocumentIsolateHandle,
}
pub(super) struct ScriptVmContextBootstrap {
@@ -269,21 +333,24 @@ pub(crate) struct ScriptVmDefaultWorldBootstrap {
pub(super) resource_owner_id: ResourceOwnerId,
pub(super) promise_reject_dispatch: PromiseRejectDispatchSlot,
pub(super) page_inspector: DocumentInspectorBinding,
pub(super) renderer_document_isolate: RendererDocumentIsolateHandle,
pub(super) renderer_document_isolate_teardown: RendererDocumentIsolateTeardown,
pub(super) renderer_page_script_environment: Option<RendererPageScriptEnvironment>,
pub(super) page_default_context: v8::Global<v8::Context>,
pub(super) bridge_ref: JsContextHostBridgeRef,
pub(super) runtime_observable_context_token: RuntimeObservableContextToken,
pub(super) baseline_globals: super::ScriptGlobalsBaseline,
pub(super) document_runtime: Box<DocumentRuntime>,
pub(super) root_frame_id: Option<String>,
pub(super) context_host: Rc<RefCell<JsContextHost>>,
pub(super) prebootstrapped_child_default_contexts: SharedPrebootstrappedChildDefaultContexts,
pub(super) page_context_cancel_tx: RendererPageContextCancelSender,
pub(super) post_domcontentloaded_page_task_tx: PageTaskSender,
pub(super) page_runtime_wake_tx: PageRuntimeWakeSender,
pub(super) storage_bucket_store: crate::context_bootstrap::SharedStorageBucketStore,
// Partial bootstrap can drop before publication. Release bridge owners
// before their native backing, and keep V8 alive until all of it
// has been released.
pub(super) context_host: Rc<RefCell<JsContextHost>>,
pub(super) document_runtime: Box<DocumentRuntime>,
pub(super) renderer_document_isolate: RendererDocumentIsolateHandle,
}
#[derive(Clone, Copy, Debug)]
@@ -342,6 +409,7 @@ impl RendererDocumentIsolateTeardown {
#[derive(Clone)]
pub(crate) struct RendererDocumentIsolateHandle {
inner: Rc<RefCell<RendererDocumentIsolateHolder>>,
deferred_context_host_releases: RendererDeferredContextHostReleaseQueue,
}
impl std::fmt::Debug for RendererDocumentIsolateHandle {
@@ -352,6 +420,12 @@ impl std::fmt::Debug for RendererDocumentIsolateHandle {
}
impl RendererDocumentIsolateHandle {
pub(crate) fn deferred_context_host_release_queue(
&self,
) -> RendererDeferredContextHostReleaseQueue {
self.deferred_context_host_releases.clone()
}
#[cfg(test)]
pub(crate) fn new_standalone_without_owner_reservation_for_test(
v8_foreground_task_sender: RendererPageV8ForegroundTaskSender,
@@ -384,7 +458,11 @@ impl RendererDocumentIsolateHandle {
foreground_wake,
devtools_target_shutdown_registry,
)?;
let deferred_context_host_releases = renderer_document_isolate
.deferred_context_host_releases
.clone();
let renderer_document_isolate = Self {
deferred_context_host_releases,
inner: Rc::new(RefCell::new(renderer_document_isolate)),
};
let isolate_backend = renderer_document_isolate.inspector_isolate_backend_handle();
@@ -445,7 +523,12 @@ impl RendererDocumentIsolateHandle {
let inspector_backend = inspector_backend
.as_mut()
.expect("document isolate Inspector backend missing before ScriptVm drop");
with_entered_owned_isolate(isolate, |isolate| op(isolate, inspector_backend))
with_entered_owned_isolate(isolate, |isolate| {
let result = op(isolate, inspector_backend);
self.deferred_context_host_releases
.drain_on_entered_isolate();
result
})
}
pub(super) fn with_entered_renderer_document_isolate_and_inspector_mut<T>(
@@ -461,7 +544,12 @@ impl RendererDocumentIsolateHandle {
let inspector_backend = inspector_backend
.as_mut()
.ok_or_else(|| anyhow!("document isolate Inspector backend unavailable"))?;
with_entered_owned_isolate(isolate, |isolate| op(isolate, inspector_backend))
with_entered_owned_isolate(isolate, |isolate| {
let result = op(isolate, inspector_backend);
self.deferred_context_host_releases
.drain_on_entered_isolate();
result
})
}
pub(super) fn with_renderer_document_isolate_mut<T>(
@@ -469,7 +557,12 @@ impl RendererDocumentIsolateHandle {
op: impl FnOnce(&mut v8::OwnedIsolate) -> T,
) -> T {
let mut holder = self.inner.borrow_mut();
with_entered_owned_isolate(&mut holder.isolate, op)
with_entered_owned_isolate(&mut holder.isolate, |isolate| {
let result = op(isolate);
self.deferred_context_host_releases
.drain_on_entered_isolate();
result
})
}
pub(super) fn with_entered_renderer_document_isolate<T>(
@@ -477,7 +570,12 @@ impl RendererDocumentIsolateHandle {
op: impl FnOnce(&mut v8::OwnedIsolate) -> Result<T>,
) -> Result<T> {
let mut holder = self.inner.borrow_mut();
with_entered_owned_isolate(&mut holder.isolate, op)
with_entered_owned_isolate(&mut holder.isolate, |isolate| {
let result = op(isolate);
self.deferred_context_host_releases
.drain_on_entered_isolate();
result
})
}
pub(super) fn with_entered_renderer_document_isolate_and_bootstrap<T>(
@@ -488,7 +586,12 @@ impl RendererDocumentIsolateHandle {
let RendererDocumentIsolateHolder {
isolate, bootstrap, ..
} = &mut *holder;
with_entered_owned_isolate(isolate, |isolate| op(isolate, &*bootstrap))
with_entered_owned_isolate(isolate, |isolate| {
let result = op(isolate, &*bootstrap);
self.deferred_context_host_releases
.drain_on_entered_isolate();
result
})
}
pub(super) fn unregister_renderer_document_isolate_platform(&self) {
@@ -506,6 +609,7 @@ impl RendererDocumentIsolateHandle {
}
pub(super) struct RendererDocumentIsolateHolder {
deferred_context_host_releases: RendererDeferredContextHostReleaseQueue,
// Unregister before destroying the Inspector backend so owner shutdown
// never observes a target whose isolate has already been disposed.
_devtools_target_shutdown_registration: Option<RendererDevToolsTargetShutdownRegistration>,
@@ -675,6 +779,7 @@ impl RendererDocumentIsolateHolder {
isolate: v8::OwnedIsolate,
) -> Self {
Self {
deferred_context_host_releases: RendererDeferredContextHostReleaseQueue::default(),
_devtools_target_shutdown_registration: devtools_target_shutdown_registration,
inspector_backend: Some(inspector_backend),
bootstrap,
@@ -694,6 +799,7 @@ impl Drop for RendererDocumentIsolateHolder {
unsafe {
self.isolate.enter();
}
self.deferred_context_host_releases.begin_isolate_shutdown();
}
}
@@ -19,5 +19,10 @@ impl Drop for ScriptVm {
.deactivate_page_vm_binding_for_teardown();
self.page_default_bridge_ref.take();
if let Some(runtime) = self.document_runtime.take_for_retained_document_host() {
self._context_host
.borrow_mut()
.adopt_retained_document_runtime(runtime);
}
}
}
@@ -406,6 +406,8 @@ impl ScriptVm {
Some(context)
}
Some(context) => {
let context_ptr = &context.context as *const v8::Global<v8::Context>;
self.clear_context_wrapper_cache_for_context_ptr(context_ptr, false);
self.cancel_history_traversals_for_retiring_window(
crate::native_bridge::WindowExecutionContextOwner::Frame(
context.local_window_id,
@@ -555,6 +557,8 @@ impl ScriptVm {
};
if !stale_prebootstrapped_contexts.is_empty() {
for (_, context) in &stale_prebootstrapped_contexts {
let context_ptr = &context.context as *const v8::Global<v8::Context>;
self.clear_context_wrapper_cache_for_context_ptr(context_ptr, false);
self.cancel_history_traversals_for_retiring_window(
crate::native_bridge::WindowExecutionContextOwner::Frame(
context.local_window_id,
@@ -623,6 +627,8 @@ impl ScriptVm {
let Some(context) = self.child_frame_realm_store.remove(&execution_context_id) else {
return;
};
let context_ptr: *const v8::Global<v8::Context> = &context.context as *const _;
self.clear_context_wrapper_cache_for_context_ptr(context_ptr, false);
self.cancel_history_traversals_for_retiring_window(
crate::native_bridge::WindowExecutionContextOwner::Frame(context.local_window_id),
);
@@ -696,8 +702,6 @@ impl ScriptVm {
retired_timer_count,
"retired child Runtime binding context"
);
let context_ptr: *const v8::Global<v8::Context> = &context.context as *const _;
self.clear_context_wrapper_cache_for_context_ptr(context_ptr, false);
assert!(
self.page_inspector
.destroy_context_registration(context.inspector_context_registration_id),
@@ -806,6 +810,8 @@ impl ScriptVm {
.destroy_context_registration(context.inspector_context_registration_id),
"isolated context must retain its document-owned Inspector registration"
);
let context_ptr: *const v8::Global<v8::Context> = &context.context as *const _;
self.clear_context_wrapper_cache_for_context_ptr(context_ptr, false);
// V8 Inspector consumes the still-identifiable realm while processing
// `context_destroyed` (including Runtime lifecycle projection for
// named child worlds). Only after that notification may the strict
@@ -363,6 +363,13 @@ impl ScriptVmContextBootstrap {
if let Some(registration) = realm_registration.as_mut() {
registration.commit();
}
// Child Window exposure can reach bootstrap while the caller holds
// the host's RefCell borrow. Follow the bootstrap's raw host access
// discipline and end each native read before touching V8 again.
if unsafe { &*host_ptr }.document_host_is_published() {
let queue = unsafe { &*host_ptr }.deferred_context_host_release_queue();
crate::util::retain_context_host_for_document_realm(local_context, context_host, queue);
}
Ok(Self {
context: v8::Global::new(scope, local_context),
runtime_observable_context_token,
@@ -2986,7 +2986,8 @@ async fn child_default_bridge_ref_is_released_on_child_context_teardown() {
)
.expect("child wrapper cache setup should evaluate");
assert_eq!(child_wrappers, "child-wrappers");
let child_wrapper_count = retained_child_cache.wrapper_entry_count_for_realm(child_realm_token);
let child_wrapper_count =
retained_child_cache.strong_wrapper_entry_count_for_realm(child_realm_token);
assert!(
child_wrapper_count >= 1,
"child context wrappers should populate its default-world cache partition: {child_wrapper_count}"
@@ -2999,7 +3000,8 @@ async fn child_default_bridge_ref_is_released_on_child_context_teardown() {
)
})
.expect("top default context token should be readable");
let top_wrapper_count = retained_child_cache.wrapper_entry_count_for_realm(top_realm_token);
let top_wrapper_count =
retained_child_cache.strong_wrapper_entry_count_for_realm(top_realm_token);
assert!(
top_wrapper_count >= 1,
"the shared default-world cache should contain live top-context wrappers"
@@ -3031,12 +3033,13 @@ async fn child_default_bridge_ref_is_released_on_child_context_teardown() {
"removed iframe should not keep a live child default context"
);
assert_eq!(
retained_child_cache.wrapper_entry_count_for_realm(child_realm_token),
retained_child_cache.strong_wrapper_entry_count_for_realm(child_realm_token),
0,
"destroying a child default context must retire its strong wrapper entries"
);
assert!(
retained_child_cache.wrapper_entry_count_for_realm(top_realm_token) >= top_wrapper_count,
retained_child_cache.strong_wrapper_entry_count_for_realm(top_realm_token)
>= top_wrapper_count,
"destroying a child context must preserve wrappers owned by the live top realm"
);
assert_eq!(
@@ -1,5 +1,77 @@
use super::*;
#[test]
fn retained_document_realm_keeps_native_values_until_the_last_v8_reference() {
let mut vm = new_parsed_test_vm(
"https://retained-document.test/old",
"<!doctype html><p>old document</p>",
);
vm.eval(
r#"
globalThis.savedNode = document.querySelector('p');
globalThis.savedDecoder = new TextDecoder();
globalThis.savedBlob = new Blob(['retained blob']);
globalThis.savedController = new AbortController();
globalThis.savedSignal = savedController.signal;
globalThis.savedComposite = AbortSignal.any([savedSignal]);
globalThis.abortEventRan = false;
savedSignal.onabort = () => { abortEventRan = true; };
globalThis.savedReason = {kind: 'old realm'};
globalThis.savedFunction = () => [savedNode.textContent, document.URL];
'ready'
"#,
)
.unwrap();
let weak_host = vm.context_host_weak_for_test();
let isolate = vm.renderer_document_isolate.clone();
let context = vm.page_default_context.clone();
drop(vm);
assert!(
weak_host.upgrade().is_some(),
"retained realm owns its native DOM"
);
let value = isolate.with_renderer_document_isolate_mut(|isolate| {
let scope = std::pin::pin!(v8::HandleScope::new(isolate));
let scope = &mut scope.init();
let context = v8::Local::new(scope, &context);
let scope = &mut v8::ContextScope::new(scope, context);
let source = crate::util::v8str(scope, r#"
savedNode.textContent = 'retained';
savedController.abort(savedReason);
JSON.stringify([
savedFunction(),
savedDecoder.decode(new Uint8Array([65])),
savedBlob.size,
savedSignal.aborted,
savedSignal.reason === savedReason,
savedComposite.aborted && savedComposite.reason === savedReason,
abortEventRan,
savedNode === document.querySelector('p'),
(() => { try { savedSignal.throwIfAborted(); } catch (e) { return e === savedReason; } })()
])
"#);
let script = v8::Script::compile(scope, source, None).expect("retained realm compiles");
crate::script_execution::execute_compiled_script(scope, script)
.expect("retained native values remain usable")
.to_rust_string_lossy(scope)
});
assert_eq!(
value,
r#"[["retained","https://retained-document.test/old"],"A",13,true,true,true,false,true,true]"#
);
isolate.with_renderer_document_isolate_mut(|isolate| {
drop(context);
isolate.low_memory_notification();
});
isolate.with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification());
assert!(
weak_host.upgrade().is_none(),
"last V8 reference releases native DOM outside GC"
);
}
#[test]
fn runtime_binding_calls_freeze_the_invoking_realm_generation() {
let mut vm = new_storage_test_vm("https://runtime-binding-source-realm.test/");
@@ -307,23 +379,33 @@ fn page_context_teardown_releases_opfs_handle_and_directory_iterator_registratio
vm.close_page_context_resources_for_context_teardown();
let handles = vm._context_host.borrow().opfs_handle_registry().unwrap();
let iterators = vm
._context_host
.borrow()
.opfs_directory_iterator_registry()
.unwrap();
assert_eq!(
vm._context_host
.borrow()
.opfs_handle_registry()
.expect("OPFS handle registry remains owned until host teardown")
.len(),
0,
"page teardown must run handle finalizers before isolate teardown"
handles.len(),
1,
"retained handle remains backed while its realm is alive"
);
assert_eq!(
vm._context_host
.borrow()
.opfs_directory_iterator_registry()
.expect("OPFS iterator registry remains owned until host teardown")
.len(),
iterators.len(),
16,
"retained iterators remain backed while their realm is alive"
);
assert_eq!(vm.eval("__opfsRoot.kind").unwrap(), "directory");
drop(vm);
assert_eq!(
handles.len(),
0,
"page teardown must run iterator finalizers before isolate teardown"
"final Context release retires native handle registrations"
);
assert_eq!(
iterators.len(),
0,
"final Context release retires native iterator registrations"
);
}
#[test]
@@ -802,7 +884,7 @@ fn promise_reject_context_slot_does_not_retain_context_host_after_script_vm_drop
);
}
#[test]
fn context_wrapper_cache_is_cleared_on_script_vm_teardown() {
fn context_wrapper_cache_releases_native_roots_on_script_vm_teardown() {
let mut vm = new_parsed_test_vm(
"https://wrapper-cache-retention.test/",
"<!doctype html><main></main>",
@@ -846,7 +928,7 @@ fn context_wrapper_cache_is_cleared_on_script_vm_teardown() {
drop(vm);
assert_eq!(
retained_cache.wrapper_entry_count(),
retained_cache.strong_wrapper_entry_count(),
0,
"page context teardown must clear strong wrapper cache entries before contexts are dropped"
);
@@ -890,7 +972,7 @@ fn script_vm_page_context_teardown_is_idempotent() {
);
}
#[test]
fn page_context_teardown_releases_all_context_owned_v8_finalizers() {
fn page_context_teardown_preserves_finalizers_for_retained_native_objects() {
let mut vm = new_parsed_test_vm(
"https://v8-finalizer-teardown.test/",
"<!doctype html><body></body>",
@@ -933,10 +1015,9 @@ fn page_context_teardown_releases_all_context_owned_v8_finalizers() {
);
vm.close_page_context_resources_for_context_teardown();
assert_eq!(
vm._context_host.borrow().v8_finalizers.len(),
0,
"page context teardown must reset every weak handle before isolate teardown"
assert!(
vm._context_host.borrow().v8_finalizers.len() >= 128,
"retiring active execution must preserve native objects retained by author code"
);
assert_eq!(
vm._context_host
@@ -947,7 +1028,13 @@ fn page_context_teardown_releases_all_context_owned_v8_finalizers() {
);
vm.close_page_context_resources_for_context_teardown();
assert_eq!(vm.eval("__finalizerObjects[2].size").unwrap(), "9");
let weak_host = vm.context_host_weak_for_test();
drop(vm);
assert!(
weak_host.upgrade().is_none(),
"final isolate release must drop the native host"
);
}
#[test]
fn embedded_frame_owners_create_child_contexts_only_for_document_content() {
@@ -1737,3 +1824,97 @@ fn frame_owner_content_accessors_live_on_exact_owner_prototypes() {
r#"{"descriptors":[{"contentDocument":{"get":"function","set":"undefined","enumerable":true,"configurable":true},"contentWindow":{"get":"function","set":"undefined","enumerable":true,"configurable":true}},{"contentDocument":{"get":"function","set":"undefined","enumerable":true,"configurable":true},"contentWindow":{"get":"function","set":"undefined","enumerable":true,"configurable":true}},{"contentDocument":{"get":"function","set":"undefined","enumerable":true,"configurable":true},"contentWindow":{"get":"function","set":"undefined","enumerable":true,"configurable":true}}],"sameOriginValues":[true,true,true],"brandErrors":[["TypeError","TypeError"],["TypeError","TypeError"],["TypeError","TypeError"]],"absentFromBase":true}"#
);
}
#[test]
fn unreferenced_traversal_filters_release_retired_document() {
for method in ["createTreeWalker", "createNodeIterator"] {
let mut vm = new_parsed_test_vm("https://traversal.test/old", "<body>old document</body>");
vm.eval(&format!(
"document.{method}(document, NodeFilter.SHOW_ALL, () => NodeFilter.FILTER_ACCEPT); 1"
))
.expect("unreferenced traversal setup");
let host = vm.context_host_weak_for_test();
let isolate = vm.renderer_document_isolate.clone();
drop(vm);
for _ in 0..3 {
isolate.with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification());
}
assert!(
host.upgrade().is_none(),
"{method}: an unreferenced filter must not retain its retired Document host"
);
}
}
#[test]
fn retained_traversal_filters_release_host_after_last_reference() {
for method in ["createTreeWalker", "createNodeIterator"] {
let mut vm = new_parsed_test_vm(
"https://retained-traversal.test/old",
"<body>old document</body>",
);
vm.eval(&format!(
r#"
window.retainedFilter = () => NodeFilter.FILTER_ACCEPT;
window.retainedTraversal = document.{method}(
document.body, NodeFilter.SHOW_ALL, retainedFilter
);
1
"#
))
.expect("retained traversal setup");
let walker = vm
.with_default_context_scope_and_checkpoint_for_test(|scope, _| {
let context = scope.get_current_context();
let value = context
.global(scope)
.get(scope, crate::util::v8str(scope, "retainedTraversal").into())
.unwrap();
let walker = v8::Local::<v8::Object>::try_from(value).unwrap();
Ok(v8::Global::new(scope, walker))
})
.unwrap();
let host = vm.context_host_weak_for_test();
let isolate = vm.renderer_document_isolate.clone();
drop(vm);
for _ in 0..3 {
isolate.with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification());
}
assert!(
host.upgrade().is_some(),
"a retained walker keeps native backing alive"
);
let result = isolate.with_renderer_document_isolate_mut(|isolate| {
let scope = std::pin::pin!(v8::HandleScope::new(isolate));
let scope = &mut scope.init();
let caller = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, caller);
let walker = v8::Local::new(scope, &walker);
let context = walker.get_creation_context(scope).unwrap();
let scope = &mut v8::ContextScope::new(scope, context);
let source = crate::util::v8str(
scope,
r#"JSON.stringify([
retainedTraversal.filter === retainedFilter,
retainedTraversal.root.textContent
])"#,
);
let script = v8::Script::compile(scope, source, None).unwrap();
crate::script_execution::execute_compiled_script(scope, script)
.unwrap()
.to_rust_string_lossy(scope)
});
assert_eq!(result, r#"[true,"old document"]"#);
isolate.with_renderer_document_isolate_mut(|isolate| {
drop(walker);
isolate.low_memory_notification();
});
for _ in 0..3 {
isolate.with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification());
}
assert!(
host.upgrade().is_none(),
"the last walker reference releases its retired host"
);
}
}
@@ -613,6 +613,95 @@ fn native_dom_exception_uses_registered_prototype_after_public_override() {
.expect("native exception should use the realm registry");
}
#[test]
fn native_dom_exception_materializes_without_reading_the_public_constructor() {
let mut vm = new_storage_test_vm("https://native-dom-exception-lazy.test/");
vm.eval(
r#"
globalThis.exceptionConstructorReads = 0;
delete globalThis.DOMException;
Object.defineProperty(globalThis, 'DOMException', {
configurable: true,
get() {
++exceptionConstructorReads;
throw new Error('the public constructor must not be read');
}
});
"#,
)
.expect("public constructor override should evaluate");
assert_eq!(
constructor_materialization_count(&mut vm, "DOMException"),
0
);
vm.with_default_context_scope_and_checkpoint_for_test(|scope, _host_ptr| {
let exception = crate::context_bootstrap::new_dom_exception_value(
scope,
"native security failure",
"SecurityError",
);
let exception = v8::Local::<v8::Object>::try_from(exception).unwrap();
let intrinsic =
crate::context_bootstrap::ensure_intrinsic_interface_prototype(scope, "DOMException")?;
assert!(
exception
.get_prototype(scope)
.unwrap()
.strict_equals(intrinsic.into())
);
Ok(())
})
.expect("native exception should materialize through its realm registry");
assert_eq!(vm.eval("exceptionConstructorReads").unwrap(), "0");
assert_eq!(
constructor_materialization_count(&mut vm, "DOMException"),
1
);
}
#[test]
fn native_dom_exception_materializes_in_a_detached_realm() {
let mut vm = new_storage_test_vm("https://native-dom-exception-detached.test/");
assert_eq!(
constructor_materialization_count(&mut vm, "DOMException"),
0
);
let isolate = vm.renderer_document_isolate.clone();
let context = vm.page_default_context.clone();
drop(vm);
isolate.with_renderer_document_isolate_mut(|isolate| {
let scope = std::pin::pin!(v8::HandleScope::new(isolate));
let scope = &mut scope.init();
let context = v8::Local::new(scope, &context);
context.detach_global();
let scope = &mut v8::ContextScope::new(scope, context);
let exception = crate::context_bootstrap::new_dom_exception_value(
scope,
"native security failure",
"SecurityError",
);
let exception = v8::Local::<v8::Object>::try_from(exception).unwrap();
let intrinsic =
crate::context_bootstrap::ensure_intrinsic_interface_prototype(scope, "DOMException")
.expect("detached realm must retain its intrinsic registry");
assert!(
exception
.get_prototype(scope)
.unwrap()
.strict_equals(intrinsic.into())
);
assert_eq!(exception.get_creation_context(scope), Some(context));
assert_eq!(
exception
.get(scope, crate::util::v8str(scope, "name").into())
.unwrap()
.to_rust_string_lossy(scope),
"SecurityError"
);
});
}
#[test]
fn illegal_storage_receivers_do_not_materialize_wrappers_or_opfs_state() {
let mut vm = new_storage_test_vm("https://lazy-illegal-storage-receivers.test/");
+226 -10
View File
@@ -18,7 +18,11 @@ pub use moli_v8_util::{
};
use moli_webapi_declare::WebApiValue;
pub(crate) use moli_webapi_declare::define_array_data_property as define_v8_array_data_property;
use std::{ptr::NonNull, rc::Rc};
use std::{
cell::{Cell, RefCell},
ptr::NonNull,
rc::Rc,
};
use url::Url;
use widestring::U16String;
@@ -26,10 +30,113 @@ const SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER: &str = "moli-script-base-url-
// Access-check callbacks cannot inspect the global object to rediscover the host:
// that property lookup would recursively invoke the same V8 access check. This
// non-owning slot is valid while the context can execute because the matching
// ScriptVm context state retains its `JsContextHostBridgeRef`.
// slot starts non-owning during fallible bootstrap, then a successfully
// published Document realm promotes it to a Context-owned host reference. That
// mirrors ScriptState/Oilpan's retained-realm edge closely enough for Moli's
// non-tracing Rust heap: a retained function or DOM wrapper keeps its native
// Document backing alive, while temporary WindowProxy facades remain
// non-owning and fail closed as soon as the host detaches.
struct ContextHostPointerSlot {
host_ptr: NonNull<JsContextHost>,
host_lifecycle: Rc<Cell<ContextHostLifecycle>>,
retained_realm_owner: RefCell<Option<RetainedDocumentRealmHostOwner>>,
}
struct RetainedDocumentRealmHostOwner {
host: Option<Rc<RefCell<JsContextHost>>>,
deferred_release_queue: crate::script_vm::RendererDeferredContextHostReleaseQueue,
retained_v8_handle_state: Vec<Box<dyn std::any::Any>>,
}
impl Drop for RetainedDocumentRealmHostOwner {
fn drop(&mut self) {
if let Some(host) = self.host.take() {
self.deferred_release_queue
.defer(host, std::mem::take(&mut self.retained_v8_handle_state));
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ContextHostLifecycle {
Active,
Detached,
Destroyed,
}
/// Marks a V8 Context whose native Document host has been retired.
///
/// Navigation can keep the stable WindowProxy while replacing its Context and
/// `JsContextHost`; final Page close also parks that proxy on a host-free
/// facade. In both cases retained functions and wrappers can outlive the Rust
/// host, so every raw-pointer entry point must fail closed before inspecting
/// embedder data.
#[derive(Debug)]
struct ContextHostPointerSlot(NonNull<JsContextHost>);
struct DisconnectedPageContext;
/// Marks a real Document realm whose browsing-context execution authority has
/// ended while its ordinary JS and DOM objects remain usable if author code
/// still retains them.
#[derive(Debug)]
struct DetachedDocumentPageContext;
/// Active native registries root their objects until teardown. A detached
/// realm instead keeps those objects in its own V8-traced graph, so retaining
/// a Document preserves wrapper identity without a Rust -> V8 -> Rust cycle.
#[derive(Debug)]
pub(crate) enum RealmObjectHandle {
Strong(v8::Global<v8::Object>),
Traced(v8::Weak<v8::Object>),
}
impl RealmObjectHandle {
pub(crate) fn new(scope: &mut v8::PinScope<'_, '_>, object: v8::Local<'_, v8::Object>) -> Self {
if page_context_is_detached_document(scope.get_current_context()) {
Self::traced(scope, object)
} else {
Self::Strong(v8::Global::new(scope, object))
}
}
fn traced(scope: &mut v8::PinScope<'_, '_>, object: v8::Local<'_, v8::Object>) -> Self {
const ROOTS: &str = "__moliRetainedRealmObjects";
let context = object
.get_creation_context(scope)
.unwrap_or_else(|| scope.get_current_context());
let anchor = context.get_extras_binding_object(scope);
let roots = get_private_value(scope, anchor, ROOTS)
.and_then(|value| v8::Local::<v8::Array>::try_from(value).ok())
.unwrap_or_else(|| {
let roots = v8::Array::new(scope, 0);
roots.set_prototype(scope, v8::null(scope).into());
set_private_value(scope, anchor, ROOTS, roots.into());
roots
});
roots.set_index(scope, roots.length(), object.into());
Self::Traced(v8::Weak::new(scope, object))
}
pub(crate) fn to_local<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Object>> {
match self {
Self::Strong(object) => Some(v8::Local::new(scope, object)),
Self::Traced(object) => object.to_local(scope),
}
}
pub(crate) fn retain_in_realm(&mut self, scope: &mut v8::PinScope<'_, '_>) {
if let Self::Strong(object) = self {
let object = v8::Local::new(scope, &*object);
*self = Self::traced(scope, object);
}
}
pub(crate) fn is_strong(&self) -> bool {
matches!(self, Self::Strong(_))
}
}
/// Materializes the unexposed prototype described by a WebIDL iterator
/// FunctionTemplate. The temporary constructor is an implementation detail, so
@@ -373,6 +480,9 @@ pub(super) fn context_host_ptr_from_global_bridge(
scope: &mut v8::PinScope<'_, '_>,
) -> Option<*mut JsContextHost> {
let context = scope.get_current_context();
if page_context_is_disconnected(context) {
return None;
}
if let Some(host_ptr) = context_host_ptr_from_context_slot(context) {
return Some(host_ptr);
}
@@ -384,24 +494,123 @@ pub(super) fn context_host_ptr_from_global_bridge(
pub(super) fn install_context_host_pointer_slot(
context: v8::Local<'_, v8::Context>,
host_ptr: *mut JsContextHost,
host_lifecycle: Rc<Cell<ContextHostLifecycle>>,
) {
let host_ptr =
NonNull::new(host_ptr).expect("V8 context JsContextHost pointer should not be null");
let previous = context.set_slot(Rc::new(ContextHostPointerSlot(host_ptr)));
assert!(
previous
.as_deref()
.is_none_or(|previous| previous.0 == host_ptr),
"V8 context JsContextHost pointer must not be rebound"
host_lifecycle.get() == ContextHostLifecycle::Active,
"a retired JsContextHost must not be installed into a V8 context"
);
if let Some(existing) = context.get_slot::<ContextHostPointerSlot>() {
assert!(
existing.host_ptr == host_ptr && Rc::ptr_eq(&existing.host_lifecycle, &host_lifecycle),
"V8 context JsContextHost pointer must not be rebound"
);
return;
}
let previous = context.set_slot(Rc::new(ContextHostPointerSlot {
host_ptr,
host_lifecycle: host_lifecycle.clone(),
retained_realm_owner: RefCell::new(None),
}));
debug_assert!(previous.is_none());
}
pub(super) fn context_host_ptr_from_context_slot(
context: v8::Local<'_, v8::Context>,
) -> Option<*mut JsContextHost> {
if page_context_is_disconnected(context) {
return None;
}
context
.get_slot::<ContextHostPointerSlot>()
.map(|slot| slot.0.as_ptr())
.map(|slot| slot.host_ptr.as_ptr())
}
pub(crate) fn retain_context_host_for_document_realm(
context: v8::Local<'_, v8::Context>,
host: Rc<RefCell<JsContextHost>>,
deferred_release_queue: crate::script_vm::RendererDeferredContextHostReleaseQueue,
) {
let slot = context
.get_slot::<ContextHostPointerSlot>()
.expect("published Document realm must have a JsContextHost pointer slot");
let host_ptr = NonNull::new(host.as_ptr())
.expect("published Document realm JsContextHost pointer should not be null");
assert_eq!(
slot.host_ptr, host_ptr,
"published Document realm must retain its installed JsContextHost"
);
assert!(
slot.host_lifecycle.get() != ContextHostLifecycle::Destroyed,
"destroyed JsContextHost must not gain a retained realm owner"
);
let mut retained = slot.retained_realm_owner.borrow_mut();
if let Some(existing) = retained.as_ref() {
assert!(
existing
.host
.as_ref()
.is_some_and(|existing| Rc::ptr_eq(existing, &host)),
"Document realm JsContextHost owner must not be rebound"
);
return;
}
*retained = Some(RetainedDocumentRealmHostOwner {
host: Some(host),
deferred_release_queue,
retained_v8_handle_state: Vec::new(),
});
}
pub(crate) fn retain_context_v8_handle_state_for_safe_release<T: 'static>(
context: v8::Local<'_, v8::Context>,
state: Rc<T>,
) {
let slot = context
.get_slot::<ContextHostPointerSlot>()
.expect("retained Context embedder state requires a Document host slot");
let mut retained_owner = slot.retained_realm_owner.borrow_mut();
let retained_owner = retained_owner
.as_mut()
.expect("retained Context embedder state requires a published real realm");
retained_owner
.retained_v8_handle_state
.push(Box::new(state));
}
pub(crate) fn page_context_is_disconnected(context: v8::Local<'_, v8::Context>) -> bool {
context.get_slot::<DisconnectedPageContext>().is_some()
|| context
.get_slot::<ContextHostPointerSlot>()
.is_some_and(|slot| match slot.host_lifecycle.get() {
ContextHostLifecycle::Active => false,
ContextHostLifecycle::Detached => slot.retained_realm_owner.borrow().is_none(),
ContextHostLifecycle::Destroyed => true,
})
}
pub(crate) fn page_context_is_detached_document(context: v8::Local<'_, v8::Context>) -> bool {
context.get_slot::<DetachedDocumentPageContext>().is_some()
|| context
.get_slot::<ContextHostPointerSlot>()
.is_some_and(|slot| slot.host_lifecycle.get() == ContextHostLifecycle::Detached)
}
pub(crate) fn detach_document_page_context(context: v8::Local<'_, v8::Context>) {
if page_context_is_disconnected(context) || page_context_is_detached_document(context) {
return;
}
let slot = context
.get_slot::<ContextHostPointerSlot>()
.expect("detached Document realm must have a JsContextHost pointer slot");
assert!(
slot.retained_realm_owner.borrow().is_some(),
"only a retained real Document realm may enter detached state"
);
let previous = context.set_slot(Rc::new(DetachedDocumentPageContext));
debug_assert!(previous.is_none());
}
/// Convenience wrapper for callbacks that immediately need the host value.
@@ -445,6 +654,13 @@ pub(super) fn context_host_ptr_from_window_object(
scope: &mut v8::PinScope<'_, '_>,
object: v8::Local<'_, v8::Object>,
) -> Option<*mut JsContextHost> {
if page_context_is_disconnected(scope.get_current_context())
|| object
.get_creation_context(scope)
.is_some_and(page_context_is_disconnected)
{
return None;
}
if let Some(value) = object.get_internal_field(scope, 0)
&& let Ok(external) = v8::Local::<v8::External>::try_from(value)
{
@@ -1,4 +1,4 @@
//! V8-traced residence for a Web IDL callback function.
//! V8-traced residence for Web IDL callback functions and interfaces.
//!
//! Some Web API owners already live as JavaScript objects. Storing their
//! callbacks in independent Rust `Global` roots would keep callback-to-owner
@@ -11,13 +11,88 @@
//! Owners that need exact Window currentness must add that authorization before
//! invoking the prepared callback.
use moli_webidl_callback::{PreparedWebIdlCallbackFunction, WebIdlCallbackFunction};
use moli_webidl_callback::{
PreparedWebIdlCallbackFunction, PreparedWebIdlCallbackInterface, WebIdlCallbackFunction,
WebIdlCallbackInterface,
};
use crate::util::{get_private_value, set_private_value};
const CALLBACK_VALUE_SLOT: &str = "__lmV8TracedWebIdlCallbackValue";
const CALLBACK_RELEVANT_GLOBAL_SLOT: &str = "__lmV8TracedWebIdlCallbackRelevantGlobal";
const CALLBACK_INCUMBENT_GLOBAL_SLOT: &str = "__lmV8TracedWebIdlCallbackIncumbentGlobal";
const CALLBACK_CALLABLE_SLOT: &str = "__lmV8TracedWebIdlCallbackCallableAtConversion";
/// Callback-interface residence owned by a JavaScript wrapper. Context extras
/// retain the original realms even after their WindowProxies are detached.
pub(crate) struct V8TracedWebIdlCallbackInterface<'s> {
carrier: v8::Local<'s, v8::Object>,
}
impl<'s> V8TracedWebIdlCallbackInterface<'s> {
pub(crate) fn new(scope: &mut v8::PinScope<'s, '_>, callback: WebIdlCallbackInterface) -> Self {
let carrier = v8::Object::new(scope);
let value = callback.value(scope);
let relevant = callback
.relevant_context(scope)
.get_extras_binding_object(scope);
let incumbent = callback
.incumbent_context(scope)
.get_extras_binding_object(scope);
let callable = v8::Boolean::new(scope, callback.callable_at_conversion());
set_private_value(scope, carrier, CALLBACK_VALUE_SLOT, value);
set_private_value(
scope,
carrier,
CALLBACK_RELEVANT_GLOBAL_SLOT,
relevant.into(),
);
set_private_value(
scope,
carrier,
CALLBACK_INCUMBENT_GLOBAL_SLOT,
incumbent.into(),
);
set_private_value(scope, carrier, CALLBACK_CALLABLE_SLOT, callable.into());
Self { carrier }
}
pub(crate) const fn from_object(carrier: v8::Local<'s, v8::Object>) -> Self {
Self { carrier }
}
pub(crate) const fn into_object(self) -> v8::Local<'s, v8::Object> {
self.carrier
}
pub(crate) fn prepare(
&self,
scope: &mut v8::PinScope<'s, '_>,
) -> PreparedWebIdlCallbackInterface {
let object = |scope: &mut v8::PinScope<'s, '_>, slot| {
get_private_value(scope, self.carrier, slot)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
.expect("a traced callback interface must retain its registration facts")
};
let callback = object(scope, CALLBACK_VALUE_SLOT);
let relevant_context = object(scope, CALLBACK_RELEVANT_GLOBAL_SLOT)
.get_creation_context(scope)
.expect("callback relevant realm anchor");
let incumbent_context = object(scope, CALLBACK_INCUMBENT_GLOBAL_SLOT)
.get_creation_context(scope)
.expect("callback incumbent realm anchor");
let callable = get_private_value(scope, self.carrier, CALLBACK_CALLABLE_SLOT)
.expect("callback-interface callability at conversion")
.is_true();
PreparedWebIdlCallbackInterface::new(
scope,
callback,
relevant_context,
incumbent_context,
callable,
)
}
}
pub(crate) struct V8TracedWebIdlCallbackFunction<'s> {
carrier: v8::Local<'s, v8::Object>,
+23 -9
View File
@@ -32,15 +32,13 @@ impl WebIdlCallbackInterface {
/// Event dispatch can therefore release all registration-store borrows
/// before entering user code and its synchronous reentrancy.
pub fn prepare(&self, scope: &mut v8::PinScope<'_, '_>) -> PreparedWebIdlCallbackInterface {
PreparedWebIdlCallbackInterface {
callback: v8::Global::new(scope, v8::Local::new(scope, &self.callback)),
relevant_context: v8::Global::new(scope, v8::Local::new(scope, &self.relevant_context)),
incumbent_context: v8::Global::new(
scope,
v8::Local::new(scope, &self.incumbent_context),
),
callable_at_conversion: self.callable_at_conversion,
}
PreparedWebIdlCallbackInterface::new(
scope,
v8::Local::new(scope, &self.callback),
v8::Local::new(scope, &self.relevant_context),
v8::Local::new(scope, &self.incumbent_context),
self.callable_at_conversion,
)
}
pub fn matches<'s>(
@@ -83,6 +81,22 @@ pub struct PreparedWebIdlCallbackInterface {
}
impl PreparedWebIdlCallbackInterface {
/// Root a snapshot of callback-interface facts held by a traced owner.
pub fn new(
scope: &mut v8::PinScope<'_, '_>,
callback: v8::Local<'_, v8::Object>,
relevant_context: v8::Local<'_, v8::Context>,
incumbent_context: v8::Local<'_, v8::Context>,
callable_at_conversion: bool,
) -> Self {
Self {
callback: v8::Global::new(scope, callback),
relevant_context: v8::Global::new(scope, relevant_context),
incumbent_context: v8::Global::new(scope, incumbent_context),
callable_at_conversion,
}
}
pub fn callback<'s>(&self, scope: &mut v8::PinScope<'s, '_>) -> v8::Local<'s, v8::Object> {
v8::Local::new(scope, &self.callback)
}