diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs index 79f966e160..4d6d5d825d 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs @@ -33,8 +33,11 @@ struct ChildWindowProxyFacadeContextHandle(DomHandle); #[derive(Default)] pub(in crate::native_bridge::context_host) struct ChildWindowProxyRecords { records: HashMap, - detached_content_window_wrappers: HashMap>, - detached_content_document_wrappers: HashMap>, + // Live Windows are rooted by `records`; detached projections are traced + // through the iframe wrapper's private slots. These identity lookups must + // not keep a retired child realm alive after its last author reference. + detached_content_window_wrappers: HashMap>, + detached_content_document_wrappers: HashMap>, } #[derive(Default)] @@ -360,7 +363,7 @@ impl ChildWindowProxyRecords { ) -> Option> { self.detached_content_document_wrappers .get(&handle) - .map(|wrapper| v8::Local::new(scope, wrapper)) + .and_then(|wrapper| wrapper.to_local(scope)) } pub(in crate::native_bridge::context_host) fn set_detached_content_document( @@ -370,7 +373,7 @@ impl ChildWindowProxyRecords { document: v8::Local<'_, v8::Object>, ) { self.detached_content_document_wrappers - .insert(handle, v8::Global::new(scope, document)); + .insert(handle, v8::Weak::new(scope, document)); } pub(in crate::native_bridge::context_host) fn detached_content_window<'s>( @@ -380,7 +383,7 @@ impl ChildWindowProxyRecords { ) -> Option> { self.detached_content_window_wrappers .get(&handle) - .map(|wrapper| v8::Local::new(scope, wrapper)) + .and_then(|wrapper| wrapper.to_local(scope)) } pub(in crate::native_bridge::context_host) fn set_detached_content_window( @@ -390,7 +393,7 @@ impl ChildWindowProxyRecords { window: v8::Local<'_, v8::Object>, ) { self.detached_content_window_wrappers - .insert(handle, v8::Global::new(scope, window)); + .insert(handle, v8::Weak::new(scope, window)); } pub(in crate::native_bridge::context_host) fn clear_detached_content_surfaces( diff --git a/moli-renderer-v8/src/script_vm/child_document_modulator.rs b/moli-renderer-v8/src/script_vm/child_document_modulator.rs index 6e560d39ea..74ae5d9f14 100644 --- a/moli-renderer-v8/src/script_vm/child_document_modulator.rs +++ b/moli-renderer-v8/src/script_vm/child_document_modulator.rs @@ -82,12 +82,27 @@ impl ScriptVm { if execution_context_retired { self.cancel_history_traversals_for_retiring_window(execution_context_owner); } - if execution_context_retired - && let Some(execution_context_id) = self + if execution_context_retired { + if let Some(execution_context_id) = self .child_frame_realm_store .context_id_for_local_window_id(retired_owner.local_window_id) - { - self.destroy_child_default_context(execution_context_id); + { + self.destroy_child_default_context(execution_context_id); + } else { + // A synchronous contentDocument/contentWindow getter can + // expose a realm before its owner task promotes it. Move + // wrappers into that realm's traced graph before retiring + // the execution binding below discards strong cache entries. + let pending_context = self + .prebootstrapped_child_default_contexts + .borrow() + .values() + .find(|context| context.local_window_id == retired_owner.local_window_id) + .map(|context| context.context.clone()); + if let Some(context) = pending_context { + self.clear_context_wrapper_cache_for_context_ptr(&context, false); + } + } } let retired_timer_count = if execution_context_retired { self.document_runtime diff --git a/moli-renderer-v8/src/script_vm/tests/extracted.rs b/moli-renderer-v8/src/script_vm/tests/extracted.rs index 49e3313c3b..e4c3b17c4c 100644 --- a/moli-renderer-v8/src/script_vm/tests/extracted.rs +++ b/moli-renderer-v8/src/script_vm/tests/extracted.rs @@ -3,6 +3,7 @@ use super::*; use crate::frame_owner_model::FrameDocumentTaskOwner; mod child_documents; +mod child_realm_retention; mod child_script_execution; mod main_document_lifecycle; mod page_runtime; diff --git a/moli-renderer-v8/src/script_vm/tests/extracted/child_realm_retention.rs b/moli-renderer-v8/src/script_vm/tests/extracted/child_realm_retention.rs new file mode 100644 index 0000000000..c2e2d7ff75 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/extracted/child_realm_retention.rs @@ -0,0 +1,210 @@ +use super::*; +use moli_page_types::LayoutPolicy; + +fn collected_native_context_count(vm: &mut StandaloneScriptVmHarness) -> usize { + // Two collections also process weak handles cleared by the first pass. + // No JS WeakRef is dereferenced in the collecting task. + for _ in 0..2 { + vm.renderer_document_isolate + .with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification()); + } + vm.renderer_document_isolate_heap_usage() + .expect("native context inventory") + .number_of_native_contexts +} + +#[test] +fn retired_child_content_surfaces_do_not_root_unreachable_realms() { + for policy in [LayoutPolicy::Mock, LayoutPolicy::OnDemand] { + let mut vm = new_parsed_test_vm( + "https://child-content-retention.test/", + "", + ); + vm.set_layout_policy(policy); + let baseline = collected_native_context_count(&mut vm); + for _ in 0..4 { + vm.eval( + r#" + (() => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const child = frame.contentDocument; + child.body.textContent = 'retire this child'; + if (frame.contentWindow.document !== child) throw new Error('identity'); + frame.remove(); + return 'removed'; + })() + "#, + ) + .expect("create, expose and remove child content surfaces"); + vm.prune_stale_child_default_execution_contexts(); + } + assert_eq!(vm.child_frame_realm_store.len(), 0, "{policy:?}"); + assert_eq!( + collected_native_context_count(&mut vm), + baseline, + "{policy:?}: retiring the frame must release caches that root otherwise unreachable child realms" + ); + } +} + +#[test] +fn retained_child_content_surfaces_preserve_author_state_until_released() { + for policy in [LayoutPolicy::Mock, LayoutPolicy::OnDemand] { + let mut vm = new_parsed_test_vm( + "https://retained-child-content.test/", + "", + ); + vm.set_layout_policy(policy); + let baseline = collected_native_context_count(&mut vm); + vm.eval( + r#" + (() => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const child = frame.contentDocument; + child.body.innerHTML = '

before

'; + child.authorMarker = 42; + globalThis.savedChildDocument = child; + globalThis.savedChildWindow = frame.contentWindow; + savedChildWindow.authorMarker = 43; + globalThis.savedChildNode = child.getElementById('kept'); + frame.remove(); + return 'retained'; + })() + "#, + ) + .expect("retain author references across child detachment"); + vm.prune_stale_child_default_execution_contexts(); + assert!( + collected_native_context_count(&mut vm) > baseline, + "{policy:?}: an author reference must retain its realm" + ); + assert_eq!( + vm.eval( + r#" + savedChildNode.textContent = 'after'; + JSON.stringify([ + savedChildDocument.authorMarker, + savedChildWindow.authorMarker, + savedChildDocument.getElementById('kept') === savedChildNode, + savedChildNode.ownerDocument === savedChildDocument, + savedChildDocument.body.textContent + ]) + "#, + ) + .expect("retained author state remains observable"), + r#"[42,43,true,true,"after"]"#, + "{policy:?}" + ); + vm.eval("savedChildDocument = savedChildWindow = savedChildNode = undefined; 'released'") + .expect("release the last author references"); + assert_eq!( + collected_native_context_count(&mut vm), + baseline, + "{policy:?}: caches must not outlive the last author reference" + ); + } +} + +#[test] +fn promoted_child_content_surfaces_follow_author_reachability() { + for policy in [LayoutPolicy::Mock, LayoutPolicy::OnDemand] { + let mut vm = new_parsed_test_vm( + "https://promoted-child-content.test/", + "", + ); + vm.set_layout_policy(policy); + let baseline = collected_native_context_count(&mut vm); + vm.eval( + r#" + (() => { + globalThis.savedFrame = document.createElement('iframe'); + document.body.append(savedFrame); + const child = savedFrame.contentDocument; + child.authorMarker = 42; + child.body.innerHTML = '

before

'; + globalThis.savedChildNode = child.getElementById('kept'); + savedChildNode.authorMarker = 43; + return 'live'; + })() + "#, + ) + .expect("expose live child surfaces"); + assert!(vm.run_child_realm_materialization_body_for_test().unwrap()); + assert_eq!(vm.child_frame_realm_store.len(), 1); + assert!(collected_native_context_count(&mut vm) > baseline); + assert_eq!( + vm.eval( + "savedFrame.contentDocument === savedChildNode.ownerDocument && \ + savedFrame.contentWindow.document === savedChildNode.ownerDocument", + ) + .unwrap(), + "true", + "{policy:?}: live registry owns the surfaces across GC" + ); + vm.eval("savedFrame.remove(); savedFrame = undefined; 'removed'") + .unwrap(); + vm.prune_stale_child_default_execution_contexts(); + assert_eq!(vm.child_frame_realm_store.len(), 0); + assert!(collected_native_context_count(&mut vm) > baseline); + assert_eq!( + vm.eval( + r#" + savedChildNode.textContent = 'after'; + JSON.stringify([ + savedChildNode.authorMarker, + savedChildNode.ownerDocument.authorMarker, + savedChildNode.ownerDocument.getElementById('kept') === savedChildNode, + savedChildNode.ownerDocument.body.textContent + ]) + "#, + ) + .unwrap(), + r#"[43,42,true,"after"]"#, + "{policy:?}: retaining only a Node preserves its Document and identity" + ); + vm.eval("savedChildNode = undefined; 'released'").unwrap(); + assert_eq!( + collected_native_context_count(&mut vm), + baseline, + "{policy:?}" + ); + } +} + +#[test] +fn detached_iframe_projection_keeps_its_cached_surfaces_across_gc() { + let mut vm = new_storage_test_vm("https://detached-content-cache.test/"); + vm.eval( + r#" + (() => { + const detached = document.implementation.createHTMLDocument(''); + globalThis.savedDetachedFrame = detached.createElement('iframe'); + detached.body.append(savedDetachedFrame); + const child = savedDetachedFrame.contentDocument; + const win = savedDetachedFrame.contentWindow; + child.authorMarker = 42; + win.authorMarker = 43; + child.body.innerHTML = '

kept

'; + return 'cached'; + })() + "#, + ) + .unwrap(); + collected_native_context_count(&mut vm); + assert_eq!( + vm.eval( + r#" + JSON.stringify([ + savedDetachedFrame.contentDocument.authorMarker, + savedDetachedFrame.contentWindow.authorMarker, + savedDetachedFrame.contentWindow.document === savedDetachedFrame.contentDocument, + savedDetachedFrame.contentDocument.body.textContent + ]) + "#, + ) + .unwrap(), + r#"[42,43,true,"kept"]"# + ); +}