diff --git a/moli-renderer-v8/src/network_host/body_source.rs b/moli-renderer-v8/src/network_host/body_source.rs index bedfc3bf50..fb8ae88879 100644 --- a/moli-renderer-v8/src/network_host/body_source.rs +++ b/moli-renderer-v8/src/network_host/body_source.rs @@ -5,7 +5,7 @@ use crate::context_bootstrap::{ }; use crate::protocol_types::SubresourceResponseBody; use crate::types::NetworkBodySourceId; -use crate::util::{RealmObjectHandle, get_private_value, set_private_value}; +use crate::util::{PendingPromiseResolver, get_private_value, set_private_value}; use crate::web_api_interfaces; use crate::worker::get_worker_state; use std::collections::{HashMap, HashSet}; @@ -245,7 +245,7 @@ enum PendingBodyMaterializationKind { } pub(crate) struct PendingBodyMaterialization { - resolver: RealmObjectHandle, + resolver: PendingPromiseResolver, kind: PendingBodyMaterializationKind, } @@ -905,13 +905,18 @@ fn pending_body_active_branch_count( } fn cancel_pending_network_body_source_in_maps( + scope: &mut v8::PinScope<'_, '_>, sources: &mut HashMap, clones: &mut HashMap>, id: NetworkBodySourceId, ) -> Option { let root_id = pending_body_clone_root(clones, id); let has_descendants = !pending_body_clone_descendants(clones, id).is_empty(); - sources.remove(&id); + if let Some(state) = sources.remove(&id) { + for materialization in state.materializations { + let _ = materialization.resolver.into_local(scope); + } + } if !has_descendants { clones.remove(&id); remove_pending_body_clone_link(clones, id); @@ -1553,7 +1558,7 @@ fn inspect_pending_body_source_for_materialization<'s>( sources.remove(&id); } else { state.materializations.push(PendingBodyMaterialization { - resolver: RealmObjectHandle::new(scope, resolver.into()), + resolver: PendingPromiseResolver::new(scope, resolver), kind, }); } @@ -1797,11 +1802,9 @@ fn resolve_pending_body_materializations( let mut bytes: Vec = take_pending_network_body_source_bytes(scope, id).unwrap_or_default(); let materialization_count = materializations.len(); for (index, materialization) in materializations.into_iter().enumerate() { - let Some(resolver) = materialization.resolver.to_local(scope) else { + let Some(resolver) = materialization.resolver.into_local(scope) else { continue; }; - // The private handle is populated only from a PromiseResolver. - let resolver = unsafe { v8::Local::::cast_unchecked(resolver) }; // Most body sources have exactly one pending materialization. Move the // completed buffer into that consumer and clone only when fan-out is // actually needed. @@ -1839,11 +1842,9 @@ fn reject_pending_body_materializations_with_reason<'s>( reason: v8::Local<'s, v8::Value>, ) { for materialization in materializations { - let Some(resolver) = materialization.resolver.to_local(scope) else { + let Some(resolver) = materialization.resolver.into_local(scope) else { continue; }; - // The private handle is populated only from a PromiseResolver. - let resolver = unsafe { v8::Local::::cast_unchecked(resolver) }; let _ = resolver.reject(scope, reason); } } @@ -2046,6 +2047,7 @@ fn pending_body_source_cancel_callback<'s>( if let Some(id) = registry_body_source_id(scope, args.this()) { if let Some(host) = context_host_mut(scope) { if let Some(root_id) = cancel_pending_network_body_source_in_maps( + scope, &mut host.pending_network_body_sources, &mut host.pending_network_body_clones, id, @@ -2056,6 +2058,7 @@ fn pending_body_source_cancel_callback<'s>( let mut worker_state = worker_state.borrow_mut(); let worker_state = &mut *worker_state; let _ = cancel_pending_network_body_source_in_maps( + scope, &mut worker_state.pending_network_body_sources, &mut worker_state.pending_network_body_clones, id, diff --git a/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs b/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs index ded4db0b0f..1ee55ab940 100644 --- a/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs +++ b/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs @@ -167,6 +167,203 @@ fn retained_response_body_keeps_bytes_until_the_last_v8_reference() { } } +enum RetiredBodyCompletion<'a> { + Bytes(&'a [u8]), + Error, + Cancel, +} + +fn assert_body_result_is_collectible_in_retained_realm( + method: &str, + completion: RetiredBodyCompletion<'_>, + expected_state: Option, +) { + let (mut vm, body_source_id) = new_vm_with_pending_response_for_teardown_test(); + vm.eval(&format!( + "globalThis.bodyPromise = response.{method}();\n\ + bodyPromise.catch(() => {{}});\n\ + globalThis.keepRealmAlive = () => document.URL;" + )) + .unwrap(); + if matches!(completion, RetiredBodyCompletion::Cancel) { + vm.with_default_context_scope_and_checkpoint_for_test(|scope, _| { + let global = scope.get_current_context().global(scope); + let response = global + .get(scope, crate::util::v8str(scope, "response").into()) + .unwrap(); + let response = v8::Local::::try_from(response).unwrap(); + let source = crate::util::get_private_value(scope, response, "__lmNetworkBodySource") + .expect("the pending Response has a native body source"); + let _ = global.set( + scope, + crate::util::v8str(scope, "cancelBody").into(), + source, + ); + Ok(()) + }) + .unwrap(); + } + let (callback, promise) = vm + .with_default_context_scope_and_checkpoint_for_test(|scope, _| { + let global = scope.get_current_context().global(scope); + let callback = global + .get(scope, crate::util::v8str(scope, "keepRealmAlive").into()) + .unwrap(); + let callback = v8::Local::::try_from(callback).unwrap(); + let promise = global + .get(scope, crate::util::v8str(scope, "bodyPromise").into()) + .unwrap(); + let promise = v8::Local::::try_from(promise).unwrap(); + Ok(( + v8::Global::new(scope, callback), + v8::Weak::new(scope, promise), + )) + }) + .unwrap(); + vm.eval("globalThis.response = null; globalThis.bodyPromise = null") + .unwrap(); + let weak_host = vm.context_host_weak_for_test(); + let isolate = vm.renderer_document_isolate.clone(); + drop(vm); + for _ in 0..2 { + isolate.with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification()); + } + assert!(weak_host.upgrade().is_some()); + + 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_context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, caller_context); + let callback = v8::Local::new(scope, &callback); + let context = callback.get_creation_context(scope).unwrap(); + let scope = &mut v8::ContextScope::new(scope, context); + let promise = promise + .to_local(scope) + .expect("a pending body resolver must survive retirement and GC"); + assert_eq!(promise.state(), v8::PromiseState::Pending); + match completion { + RetiredBodyCompletion::Bytes(bytes) => { + crate::network_host::enqueue_pending_network_body_chunk( + scope, + body_source_id, + bytes.to_vec(), + ); + crate::network_host::close_pending_network_body_stream(scope, body_source_id); + } + RetiredBodyCompletion::Error => { + crate::network_host::error_pending_network_body_stream( + scope, + body_source_id, + "body failed".to_owned(), + ); + } + RetiredBodyCompletion::Cancel => { + // Exercise the native cancellation protocol directly. + let global = context.global(scope); + let source = global + .get(scope, crate::util::v8str(scope, "cancelBody").into()) + .unwrap(); + let source = v8::Local::::try_from(source).unwrap(); + let cancel = source + .get(scope, crate::util::v8str(scope, "cancel").into()) + .unwrap(); + let cancel = v8::Local::::try_from(cancel).unwrap(); + crate::script_execution::call_function(scope, cancel, source.into(), &[]) + .expect("the retained native body source should be cancellable"); + let _ = global.set( + scope, + crate::util::v8str(scope, "cancelBody").into(), + v8::null(scope).into(), + ); + } + } + context + .get_microtask_queue() + .unwrap() + .perform_checkpoint(scope); + expected_state.map(|state| { + assert_eq!(promise.state(), state); + let result = v8::Local::::try_from(promise.result(scope)).unwrap(); + v8::Weak::new(scope, result) + }) + }); + for _ in 0..2 { + isolate.with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification()); + } + assert!( + weak_host.upgrade().is_some(), + "the unrelated callback must still retain the retired Document" + ); + isolate.with_renderer_document_isolate_mut(|isolate| { + let scope = std::pin::pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + if let Some(result) = result { + assert!( + result.to_local(scope).is_none(), + "a completed body result must be collectible before its retired realm" + ); + } + assert!( + promise.to_local(scope).is_none(), + "a completed or cancelled body resolver must no longer be rooted by its realm" + ); + }); + isolate.with_renderer_document_isolate_mut(|_| drop(callback)); + for _ in 0..2 { + isolate.with_renderer_document_isolate_mut(|isolate| isolate.low_memory_notification()); + } + assert!( + weak_host.upgrade().is_none(), + "releasing the unrelated callback must release the retired Document" + ); +} + +#[test] +fn resolved_json_body_result_is_collectible_in_retained_realm() { + assert_body_result_is_collectible_in_retained_realm( + "json", + RetiredBodyCompletion::Bytes(br#"{"ok":true}"#), + Some(v8::PromiseState::Fulfilled), + ); +} + +#[test] +fn resolved_array_buffer_body_result_is_collectible_in_retained_realm() { + assert_body_result_is_collectible_in_retained_realm( + "arrayBuffer", + RetiredBodyCompletion::Bytes(b"body bytes"), + Some(v8::PromiseState::Fulfilled), + ); +} + +#[test] +fn invalid_json_body_error_is_collectible_in_retained_realm() { + assert_body_result_is_collectible_in_retained_realm( + "json", + RetiredBodyCompletion::Bytes(b"invalid json"), + Some(v8::PromiseState::Rejected), + ); +} + +#[test] +fn network_body_error_is_collectible_in_retained_realm() { + assert_body_result_is_collectible_in_retained_realm( + "json", + RetiredBodyCompletion::Error, + Some(v8::PromiseState::Rejected), + ); +} + +#[test] +fn cancelled_body_promise_is_collectible_in_retained_realm() { + assert_body_result_is_collectible_in_retained_realm( + "json", + RetiredBodyCompletion::Cancel, + None, + ); +} + #[test] fn retained_response_body_keeps_error_reason_until_the_last_v8_reference() { let (mut vm, body_source_id) = new_vm_with_pending_response_for_teardown_test(); diff --git a/moli-renderer-v8/src/util.rs b/moli-renderer-v8/src/util.rs index b1ef55c35a..620e487e8f 100644 --- a/moli-renderer-v8/src/util.rs +++ b/moli-renderer-v8/src/util.rs @@ -149,6 +149,90 @@ impl RealmObjectHandle { } } +/// A uniquely owned pending resolver needs a realm root only until its native +/// operation ends. Identity caches use `RealmObjectHandle` instead, since their +/// roots intentionally last as long as the realm. +pub(crate) enum PendingPromiseResolver { + Strong(v8::Global), + Traced(v8::Weak), +} + +impl PendingPromiseResolver { + const ROOTS: &str = "__moliPendingRealmResolvers"; + + pub(crate) fn new( + scope: &mut v8::PinScope<'_, '_>, + resolver: v8::Local<'_, v8::PromiseResolver>, + ) -> Self { + if page_context_is_detached_document(scope.get_current_context()) { + Self::traced(scope, resolver) + } else { + Self::Strong(v8::Global::new(scope, resolver)) + } + } + + fn traced( + scope: &mut v8::PinScope<'_, '_>, + resolver: v8::Local<'_, v8::PromiseResolver>, + ) -> Self { + let context = resolver + .get_creation_context(scope) + .unwrap_or_else(|| scope.get_current_context()); + // Create the collection in its owning realm even when main-document + // teardown visits a resolver belonging to a shared child world. + let scope = &mut v8::ContextScope::new(scope, context); + let anchor = context.get_extras_binding_object(scope); + let roots = get_private_value(scope, anchor, Self::ROOTS) + .and_then(|value| v8::Local::::try_from(value).ok()) + .unwrap_or_else(|| { + let roots = v8::Set::new(scope); + set_private_value(scope, anchor, Self::ROOTS, roots.into()); + roots + }); + let _ = roots.add(scope, resolver.into()); + Self::Traced(v8::Weak::new(scope, resolver)) + } + + pub(crate) fn to_local<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + ) -> Option> { + match self { + Self::Strong(resolver) => Some(v8::Local::new(scope, resolver)), + Self::Traced(resolver) => resolver.to_local(scope), + } + } + + pub(crate) fn retain_in_realm(&mut self, scope: &mut v8::PinScope<'_, '_>) { + if let Self::Strong(resolver) = self { + let resolver = v8::Local::new(scope, &*resolver); + *self = Self::traced(scope, resolver); + } + } + + /// Release the realm edge before settling or cancelling the operation. + /// The returned Local keeps the resolver alive for the rest of this scope. + pub(crate) fn into_local<'s>( + self, + scope: &mut v8::PinScope<'s, '_>, + ) -> Option> { + let resolver = self.to_local(scope)?; + if matches!(self, Self::Traced(_)) { + let context = resolver + .get_creation_context(scope) + .unwrap_or_else(|| scope.get_current_context()); + let scope = &mut v8::ContextScope::new(scope, context); + let anchor = context.get_extras_binding_object(scope); + if let Some(roots) = get_private_value(scope, anchor, Self::ROOTS) + .and_then(|value| v8::Local::::try_from(value).ok()) + { + let _ = roots.delete(scope, resolver.into()); + } + } + Some(resolver) + } +} + /// Value edges owned by native state become ordinary V8 edges when a realm /// retires. A private holder supports primitives as well as objects and keeps /// cross-realm values attached to the realm that owns the native state.