fix(renderer): release retired child content surfaces

Backport the shared iframe realm-retention fix and its GC regressions.
Adapt mode coverage to main's Mock and OnDemand policies.

Validated with cargo fmt --all, full all-feature Clippy, and
20,081 passing nextest tests (17 existing skips).

(cherry picked from commit 1b40c6f53b)
This commit is contained in:
ldm0
2026-10-13 22:42:24 +08:00
committed by Donough Liu
parent cd24b7d260
commit 3239d00706
4 changed files with 239 additions and 10 deletions
@@ -33,8 +33,11 @@ struct ChildWindowProxyFacadeContextHandle(DomHandle);
#[derive(Default)]
pub(in crate::native_bridge::context_host) struct ChildWindowProxyRecords {
records: HashMap<DomHandle, ChildWindowProxyRecord>,
detached_content_window_wrappers: HashMap<DomHandle, v8::Global<v8::Object>>,
detached_content_document_wrappers: HashMap<DomHandle, v8::Global<v8::Object>>,
// 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<DomHandle, v8::Weak<v8::Object>>,
detached_content_document_wrappers: HashMap<DomHandle, v8::Weak<v8::Object>>,
}
#[derive(Default)]
@@ -360,7 +363,7 @@ impl ChildWindowProxyRecords {
) -> Option<v8::Local<'s, v8::Object>> {
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<v8::Local<'s, v8::Object>> {
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(
@@ -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
@@ -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;
@@ -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/",
"<!doctype html><body></body>",
);
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/",
"<!doctype html><body></body>",
);
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 = '<p id="kept">before</p>';
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/",
"<!doctype html><body></body>",
);
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 = '<p id="kept">before</p>';
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 = '<p>kept</p>';
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"]"#
);
}