diff --git a/moli-core/tests/fixtures/runtime/local_event_target_callback_exception.html b/moli-core/tests/fixtures/runtime/local_event_target_callback_exception.html index b365c92d62..282ae12bfe 100644 --- a/moli-core/tests/fixtures/runtime/local_event_target_callback_exception.html +++ b/moli-core/tests/fixtures/runtime/local_event_target_callback_exception.html @@ -30,12 +30,13 @@ throw new Error("font handler boom"); }; try { - document.fonts.load("italic bold 16px Roboto"); + document.fonts.load("italic bold 16px Roboto") + .then(() => document.fonts.ready) + .then(() => body.setAttribute("data-font-order", fontOrder.join("|"))); body.setAttribute("data-font-continued", "true"); } catch (error) { body.setAttribute("data-font-error", String(error && error.message || error)); } - body.setAttribute("data-font-order", fontOrder.join("|")); const mql = matchMedia("(min-width: 1px)"); const mqlOrder = []; diff --git a/moli-core/tests/fixtures/runtime/runtime_owned_external_async_fast_does_not_overtake_domcontentloaded.html b/moli-core/tests/fixtures/runtime/runtime_owned_external_async_fast_does_not_overtake_domcontentloaded.html index 02e05da920..25f488db8f 100644 --- a/moli-core/tests/fixtures/runtime/runtime_owned_external_async_fast_does_not_overtake_domcontentloaded.html +++ b/moli-core/tests/fixtures/runtime/runtime_owned_external_async_fast_does_not_overtake_domcontentloaded.html @@ -11,6 +11,9 @@ window.runtimeOwnedAsyncFastOrder.push("load:" + document.readyState); window.runtimeOwnedAsyncFastLoadOrder = window.runtimeOwnedAsyncFastOrder.join(","); + if (window.runtimeOwnedAsyncFastTailGated) { + fetch('/assets/runtime_owned_async_fast_executed'); + } }; document.head.appendChild(script); window.runtimeOwnedAsyncFastOrder.push("after-append:async=" + script.async); diff --git a/moli-core/tests/history_child.rs b/moli-core/tests/history_child.rs index 7ab926a6b6..16af268023 100644 --- a/moli-core/tests/history_child.rs +++ b/moli-core/tests/history_child.rs @@ -4421,7 +4421,11 @@ async fn child_browsing_context_navigation_forward_result_promises_stay_window_l )) .await?; browser - .wait_for_page_delay(&mut page, Duration::from_millis(250)) + .wait_for_script_truthy( + &mut page, + r#"document.body.dataset.childTimeoutFinished === "true""#, + Duration::from_secs(2), + ) .await?; assert!( diff --git a/moli-core/tests/web_apis.rs b/moli-core/tests/web_apis.rs index da5ca3a62c..3494638c07 100644 --- a/moli-core/tests/web_apis.rs +++ b/moli-core/tests/web_apis.rs @@ -854,9 +854,16 @@ async fn local_event_target_callback_exceptions_do_not_abort_dispatch() -> Resul let server = FixtureServer::spawn().await?; let browser = Browser::new(AppConfig::default())?; - let page = browser + let mut page = browser .fetch(&server.url("/compat/local-event-target-callback-exception")) .await?; + browser + .wait_for_script_truthy( + &mut page, + r#"document.body.dataset.fontOrder === "first|second|handler""#, + Duration::from_secs(3), + ) + .await?; assert!( page.serialize_html_async() @@ -2344,9 +2351,16 @@ async fn document_fonts_remove_event_listener_keeps_other_event_types_intact() - let server = FixtureServer::spawn().await?; let browser = Browser::new(AppConfig::default())?; - let page = browser + let mut page = browser .fetch(&server.url("/compat/document-fonts-events")) .await?; + browser + .wait_for_script_truthy( + &mut page, + r#"document.body.dataset.loadingdone === "1""#, + Duration::from_secs(3), + ) + .await?; assert!( page.serialize_html_async() @@ -4035,9 +4049,16 @@ async fn chrome_fontfaceset_event_handlers_fire_in_loading_then_done_order_subse let server = FixtureServer::spawn().await?; let browser = Browser::new(AppConfig::default())?; - let page = browser + let mut page = browser .fetch(&server.url("/compat/chrome-fontfaceset-events-subset")) .await?; + browser + .wait_for_script_truthy( + &mut page, + r#"document.body.dataset.readyStatus === "loaded""#, + Duration::from_secs(3), + ) + .await?; assert!( page.serialize_html_async() diff --git a/moli-csp/src/tests.rs b/moli-csp/src/tests.rs index e2bc583a09..b73706ef32 100644 --- a/moli-csp/src/tests.rs +++ b/moli-csp/src/tests.rs @@ -700,6 +700,36 @@ fn worker_src_none_blocks_shared_worker_script() { )); } +#[test] +fn document_font_requests_use_font_src_and_default_src() { + let kind = ContentSecurityPolicyResourceKind::DocumentFont; + assert!(!allowed( + "font-src 'none'; connect-src *", + kind, + "https://app.test/font.woff2" + )); + assert!(allowed( + "font-src 'self'; default-src 'none'; connect-src 'none'", + kind, + "https://app.test/font.woff2" + )); + assert!(!allowed( + "font-src 'self'; default-src *", + kind, + "https://cdn.test/font.woff2" + )); + assert!(!allowed( + "default-src 'none'; connect-src *", + kind, + "https://app.test/font.woff2" + )); + assert!(allowed( + "default-src 'self'; connect-src 'none'", + kind, + "https://app.test/font.woff2" + )); +} + #[test] fn shared_worker_script_uses_script_src_and_default_src_fallbacks() { assert!(!allowed( diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs index 1932073365..811524c7f0 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/events.rs @@ -265,6 +265,53 @@ pub(super) fn dispatch_font_face_set_event<'s>( const READY_RESOLVER: &str = "__moliFontFaceSetReadyResolver"; const LOADED_FACES: &str = "__moliFontFaceSetLoadedFaces"; const FAILED_FACES: &str = "__moliFontFaceSetFailedFaces"; +const FINISH_QUEUED: &str = "__moliFontFaceSetFinishQueued"; + +fn queue_font_face_set_task<'s>( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, + finish: bool, +) { + let Some(context) = owner.get_creation_context(scope) else { + return; + }; + let scope = &mut v8::ContextScope::new(scope, context); + let Some(host_ptr) = crate::util::context_host_ptr_from_global_bridge(scope) else { + return; + }; + let data = v8::Array::new_with_elements( + scope, + &[owner.into(), v8::Boolean::new(scope, finish).into()], + ); + let callback = v8::Function::builder(run_font_face_set_task) + .data(data.into()) + .build(scope) + .expect("font loading task"); + // The internal task scheduler captures this realm's exact Window/Document + // lifetime and discards the callback when that owner retires. + unsafe { &mut *host_ptr }.queue_internal_task(scope, callback); +} + +fn run_font_face_set_task<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let data = v8::Local::::try_from(args.data()).expect("font loading task data"); + let owner = data + .get_index(scope, 0) + .and_then(|value| v8::Local::::try_from(value).ok()) + .expect("font loading task owner"); + if data + .get_index(scope, 1) + .is_some_and(|value| value.is_true()) + { + set_private_value(scope, owner, FINISH_QUEUED, v8::undefined(scope).into()); + finish_font_set_task(scope, owner); + } else { + let _ = dispatch_font_face_set_event(scope, owner, "loading", None); + } +} pub(super) fn font_face_loading_started<'s>( scope: &mut v8::PinScope<'s, '_>, @@ -297,7 +344,7 @@ pub(super) fn font_face_set_loading_started<'s>( let array = v8::Array::new(scope, 0); set_private_value(scope, owner, slot, array.into()); } - let _ = dispatch_font_face_set_event(scope, owner, "loading", None); + queue_font_face_set_task(scope, owner, false); } pub(super) fn notify_font_face_set_owners_of_load<'s>( @@ -340,13 +387,31 @@ pub(super) fn finish_font_set_if_idle<'s>( scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, ) { - if super::loading::string_slot(scope, owner, FONT_FACE_SET_STATUS_SLOT) != "loading" { + if !font_set_is_loading_and_idle(scope, owner) + || get_private_value(scope, owner, FINISH_QUEUED).is_some_and(|value| value.is_true()) + { return; } + set_private_value( + scope, + owner, + FINISH_QUEUED, + v8::Boolean::new(scope, true).into(), + ); + queue_font_face_set_task(scope, owner, true); +} + +fn font_set_is_loading_and_idle<'s>( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, +) -> bool { + if super::loading::string_slot(scope, owner, FONT_FACE_SET_STATUS_SLOT) != "loading" { + return false; + } // Native CSS requests can be pending before a JS wrapper is projected. // Conversely, the native CSS queue does not contain JS-created faces. if super::storage::document_font_face_set_has_pending_requests(scope, owner) { - return; + return false; } let pending = super::storage::font_face_set_faces_array(scope, owner).is_some_and(|faces| { (0..faces.length()).any(|index| { @@ -359,7 +424,13 @@ pub(super) fn finish_font_set_if_idle<'s>( }) }) }); - if pending { + !pending +} + +fn finish_font_set_task<'s>(scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>) { + // Another font may have started between queuing this task and running it. + // Keep its ready promise pending and let its completion queue a new task. + if !font_set_is_loading_and_idle(scope, owner) { return; } let events = [ @@ -369,6 +440,7 @@ pub(super) fn finish_font_set_if_idle<'s>( .map(|(slot, event)| { let faces = get_private_value(scope, owner, slot) .and_then(|v| v8::Local::::try_from(v).ok()); + set_private_value(scope, owner, slot, v8::undefined(scope).into()); (faces, event) }); set_font_face_set_status(scope, owner, "loaded"); diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs index 0a95f0f7dc..30fa432dc6 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/loading.rs @@ -5,6 +5,7 @@ use crate::font_loading::{FontFaceLoad, FontFaceResource, FontFaceStatus}; use crate::util::{get_private_value, set_private_value}; const RESOLVER: &str = "__moliFontFaceResolver"; +const PROMISE_OWNER: &str = "__moliFontFacePromiseOwner"; const OWNER_DOCUMENT: &str = "__moliFontFaceSetOwnerDocument"; static NEXT_REGISTRATION: AtomicU64 = AtomicU64::new(1); const QUERY_RESOLVER: &str = "__moliFontQueryResolver"; @@ -147,6 +148,7 @@ fn descriptor<'s>( &string_slot(scope, face, FONT_FACE_WEIGHT_SLOT), &string_slot(scope, face, FONT_FACE_STRETCH_SLOT), &string_slot(scope, face, FONT_FACE_STYLE_SLOT), + &string_slot(scope, face, FONT_FACE_UNICODE_RANGE_SLOT), ) } @@ -166,6 +168,16 @@ pub(super) fn initialize_resolver<'s>( resource: FontFaceLoad, ) { set_private_value(scope, face, RESOLVER, resolver.into()); + // A retained pending .loaded promise must retain its settlement endpoint, + // including CSS-started loads with no explicit JS network request root. + // Keep this edge in the JS heap so an otherwise unreachable cycle remains + // collectible; no isolate-wide Global may keep a retired document alive. + set_private_value( + scope, + resolver.get_promise(scope).into(), + PROMISE_OWNER, + face.into(), + ); super::state::initialize(scope, face, resource); publish_state(scope, face); } @@ -187,6 +199,7 @@ fn publish_state<'s>(scope: &mut v8::PinScope<'s, '_>, face: v8::Local<'s, v8::O FontFaceStatus::Loaded => { sync_owners(scope, face); if let Some(resolver) = resolver_from_slot(scope, face, RESOLVER) { + release_promise_owner(scope, face, resolver); let _ = resolver.resolve(scope, face.into()); } } @@ -194,6 +207,7 @@ fn publish_state<'s>(scope: &mut v8::PinScope<'s, '_>, face: v8::Local<'s, v8::O if let Some(resolver) = resolver_from_slot(scope, face, RESOLVER) { let error = error.expect("native failed font has an error"); let exception = new_dom_exception_value(scope, error.message, error.name); + release_promise_owner(scope, face, resolver); let _ = resolver.reject(scope, exception); } } @@ -204,6 +218,20 @@ fn publish_state<'s>(scope: &mut v8::PinScope<'s, '_>, face: v8::Local<'s, v8::O } } +fn release_promise_owner<'s>( + scope: &mut v8::PinScope<'s, '_>, + face: v8::Local<'s, v8::Object>, + resolver: v8::Local<'s, v8::PromiseResolver>, +) { + set_private_value( + scope, + resolver.get_promise(scope).into(), + PROMISE_OWNER, + v8::undefined(scope).into(), + ); + set_private_value(scope, face, RESOLVER, v8::undefined(scope).into()); +} + pub(crate) fn publish_font_face_load_changes(scope: &mut v8::PinScope<'_, '_>) { for face in super::state::changed_wrappers(scope) { if let Some(context) = face.get_creation_context(scope) { @@ -307,14 +335,25 @@ pub(super) fn sync_registration<'s>( // SAFETY: this is the owner document's live bridge; the native font service // does not invoke V8 or page code while borrowed. let host = unsafe { &*host_ptr }; - host.with_font_services(document, |services| { + let changed = host.with_font_services(document, |services| { let load = load.borrow(); if present { - if let Err(error) = load.register(services) { - tracing::warn!(%error, "failed to register FontFace"); + match load.register(services) { + Ok(Some( + moli_layout::WebFontRegistrationOutcome::Added + | moli_layout::WebFontRegistrationOutcome::Replaced, + )) => true, + Ok(_) => false, + Err(error) => { + tracing::warn!(%error, "failed to register FontFace"); + false + } } } else { - load.unregister(services); + load.unregister(services) } }); + if changed { + host.invalidate_document_font_layout(document); + } } diff --git a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs index f08ec0b6f0..a883adafed 100644 --- a/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs +++ b/moli-renderer-v8/src/context_bootstrap/css_fontface_runtime/storage.rs @@ -248,9 +248,11 @@ pub(crate) fn rebuild_font_face_set_faces<'s>( } let combined = serialize_v8_iter_array(scope, combined).unwrap_or_else(|| v8::Array::new(scope, 0)); - sync_font_face_set_owners(scope, object, previous, combined); set_font_face_set_slot_value(scope, object, FONT_FACE_SET_FACES_SLOT, combined.into()); sync_font_face_set_size(scope, object); + // Publish membership before changing owners can start a loading cycle. + // Reentrant FontFaceSet queries must see the same members as that cycle. + sync_font_face_set_owners(scope, object, previous, combined); super::events::finish_font_set_if_idle(scope, object); } diff --git a/moli-renderer-v8/src/css_resource_urls.rs b/moli-renderer-v8/src/css_resource_urls.rs index a5d23766d5..72d4671585 100644 --- a/moli-renderer-v8/src/css_resource_urls.rs +++ b/moli-renderer-v8/src/css_resource_urls.rs @@ -301,7 +301,7 @@ fn parsed_web_font_face(css_text: &str) -> Option { }) } -fn parse_font_unicode_ranges(value: &str) -> Option> { +pub(crate) fn parse_font_unicode_ranges(value: &str) -> Option> { let input = value; let mut input = Parser::new(input); input diff --git a/moli-renderer-v8/src/font_loading.rs b/moli-renderer-v8/src/font_loading.rs index a4c35e4fe3..3a0b525e68 100644 --- a/moli-renderer-v8/src/font_loading.rs +++ b/moli-renderer-v8/src/font_loading.rs @@ -19,9 +19,11 @@ pub(crate) fn script_font_descriptor( weight: &str, stretch: &str, style: &str, + unicode_range: &str, ) -> WebFontFace { use crate::css_resource_urls::{ - parse_font_stretch_lower_bound, parse_font_style_lower_bound, parse_font_weight_lower_bound, + parse_font_stretch_lower_bound, parse_font_style_lower_bound, parse_font_unicode_ranges, + parse_font_weight_lower_bound, }; WebFontFace::new(moli_css_parse::unquote_css_string(family)) .with_weight(parse_font_weight_lower_bound(weight).unwrap_or(400.0)) @@ -29,6 +31,7 @@ pub(crate) fn script_font_descriptor( .with_style( parse_font_style_lower_bound(style).unwrap_or(moli_layout::WebFontStyle::Normal), ) + .with_unicode_ranges(parse_font_unicode_ranges(unicode_range).unwrap_or_default()) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -96,7 +99,7 @@ impl FontFaceResource { } pub(crate) fn set_descriptor(&mut self, descriptor: WebFontFace) { - self.descriptor = descriptor.with_unicode_ranges(self.descriptor.unicode_ranges().to_vec()); + self.descriptor = descriptor; } pub(crate) fn status(&self) -> FontFaceStatus { @@ -195,7 +198,7 @@ impl FontFaceResource { services.register_web_font(registration).map(Some) } - pub(crate) fn unregister(&self, services: &mut DocumentLayoutServices) { - services.remove_web_font(&self.slot); + pub(crate) fn unregister(&self, services: &mut DocumentLayoutServices) -> bool { + services.remove_web_font(&self.slot) } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index cf36f39bf8..e63114e687 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -317,6 +317,22 @@ impl JsContextHost { }) } + pub(crate) fn invalidate_document_font_layout(&self, document: DomHandle) { + if document != self.document_handle() + && self + .child_browsing_context_host_for_document_handle(document) + .is_none() + { + return; + } + // A real font registration change invalidates sampled glyph advances. + // Unchanged registration and read-only projections retain the tree. + self.clear_layout_rect_cache(); + self.document_layout_state + .borrow_mut() + .clear_latest_layout(); + } + pub(crate) fn build_layout_pass_for_document( &self, document: DomHandle, diff --git a/moli-renderer-v8/src/native_bridge/document/css_state/font_faces.rs b/moli-renderer-v8/src/native_bridge/document/css_state/font_faces.rs index 1912d88dff..fd881a12f2 100644 --- a/moli-renderer-v8/src/native_bridge/document/css_state/font_faces.rs +++ b/moli-renderer-v8/src/native_bridge/document/css_state/font_faces.rs @@ -105,6 +105,7 @@ fn construct_font_face_from_parts<'s>( "font-feature-settings" => "featureSettings", "font-variation-settings" => "variationSettings", "font-display" => "display", + "unicode-range" => "unicodeRange", _ => continue, }; let value = v8_string(scope, &entry.value)?; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs index d08ef11fca..9663d549b0 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts.rs @@ -3,6 +3,7 @@ use base64::Engine; use std::time::Duration; mod readiness; +mod unicode_ranges; pub(super) fn fixture_font_url() -> String { let bytes = include_bytes!(concat!( @@ -180,6 +181,7 @@ document.fonts.load('12px "Network Face"').then(faces => fontResult.push(faces[0 .unwrap(); vm.finish_async_subresource_body_checkpoint_for_test(activity) .unwrap(); + run_font_tasks(&mut vm); assert_eq!( vm.eval("JSON.stringify(fontResult)").unwrap(), "[true,\"loaded\"]" @@ -273,6 +275,7 @@ fontPromise.then(value => fontResult.push(value === face, face.status)); .unwrap(); vm.finish_async_subresource_body_checkpoint_for_test(activity) .unwrap(); + run_font_tasks(&mut vm); assert_eq!( vm.eval("JSON.stringify(fontResult)").unwrap(), r#"[true,"loaded"]"# diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/readiness.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/readiness.rs index 3edca62612..2e9a9e5105 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/readiness.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/readiness.rs @@ -11,6 +11,25 @@ async fn gated_fonts( tokio::sync::mpsc::UnboundedReceiver, FontGates, tokio::task::JoinHandle<()>, +) { + gated_fonts_with_bytes( + names, + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../moli-layout/tests/fixtures/moli-ahem.woff2" + )), + ) + .await +} + +async fn gated_fonts_with_bytes( + names: &[&str], + font: &'static [u8], +) -> ( + String, + tokio::sync::mpsc::UnboundedReceiver, + FontGates, + tokio::task::JoinHandle<()>, ) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let origin = format!("http://{}", listener.local_addr().unwrap()); @@ -39,10 +58,6 @@ async fn gated_fonts( requests_tx.send(path).unwrap(); responses.push(tokio::spawn(async move { let _ = release.await; - let font = include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../moli-layout/tests/fixtures/moli-ahem.woff2" - )); let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: font/woff2\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", font.len() @@ -121,6 +136,7 @@ async fn assert_mixed_ready(js_starts_first: bool, js_finishes_first: bool) { .unwrap(); vm.finish_async_subresource_body_checkpoint_for_test(activity) .unwrap(); + run_font_tasks(&mut vm); let expected = if index == 1 { r#"["loaded","loaded",["css:true","js:true"],["AuditCSS,AuditJS"]]"# } else if js_finishes_first { @@ -202,6 +218,7 @@ async fn font_set_ready_settles_css_started_by_explicit_font_face_load() { .unwrap(); vm.finish_async_subresource_body_checkpoint_for_test(activity) .unwrap(); + run_font_tasks(&mut vm); assert_eq!( vm.eval("JSON.stringify([face.status,readyResolved])") .unwrap(), @@ -253,3 +270,368 @@ async fn font_face_url_is_frozen_before_base_href_changes() { r#"["loaded",true]"# ); } + +async fn assert_loaded_promise_lifetime(mode: &str) { + let failure = mode == "css-removed-error"; + let (origin, mut requests, mut gates, server) = if failure { + gated_fonts_with_bytes(&["lifetime.woff2"], b"invalid font bytes").await + } else { + gated_fonts(&["lifetime.woff2"]).await + }; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + loader.set_optional_resource_fetch_mask(crate::protocol_types::OptionalResourceFetchMask::FONT); + let (mut vm, mut completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + &format!("{origin}/page"), + "AAAA", + &loader, + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + if mode == "js-only" { + vm.eval(&format!( + r#"globalThis.promiseResults=[]; + globalThis.retainedPromise=(() => {{ + const face=new FontFace('Lifetime','url("{origin}/lifetime.woff2")'); + globalThis.faceWitness=new WeakRef(face); + return face.load(); + }})(); + retainedPromise.then(value=>promiseResults.push([value.family,value.status]), + error=>promiseResults.push(['error',error.name]));"# + )) + .unwrap(); + } else { + vm.eval(&format!( + r#"const style=document.createElement('style'); + style.textContent='@font-face{{font-family:Lifetime;src:url("{origin}/lifetime.woff2")}}'; + document.head.append(style);document.body.style.fontFamily='Lifetime';"# + )).unwrap(); + // Only native CSS/layout admission starts this request. Do not call + // FontFace.load(), which installs a separate strong request root. + vm.refresh_layout_snapshot_for_test(moli_layout::LayoutViewport::new(800, 600, 1.0)) + .unwrap(); + assert_eq!( + vm.eval( + r#"globalThis.promiseResults=[]; + globalThis.retainedPromise=(() => { + const face=[...document.fonts][0]; + if(face.status!=='loading') throw new Error('CSS must already be loading'); + globalThis.faceWitness=new WeakRef(face); + return face.loaded; + })(); + retainedPromise.then(value=>promiseResults.push([value.family,value.status]), + error=>promiseResults.push(['error',error.name])); + document.fonts.size"# + ) + .unwrap(), + "1" + ); + } + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), requests.recv()) + .await + .unwrap() + .unwrap(), + "/lifetime.woff2" + ); + if mode.starts_with("css-removed") { + assert_eq!( + vm.eval("document.styleSheets[0].deleteRule(0);document.fonts.size") + .unwrap(), + "0" + ); + vm.refresh_layout_snapshot_for_test(moli_layout::LayoutViewport::new(800, 600, 1.0)) + .unwrap(); + assert_eq!(vm.document_web_font_counts_for_test(), (0, 0, 0)); + } + assert_eq!(vm.eval("JSON.stringify(promiseResults)").unwrap(), "[]"); + assert_eq!( + vm._context_host + .borrow() + .pending_subresource_request_count(), + 1 + ); + // Every vm.eval HandleScope above has exited. Clear WeakRef's job-local + // keepalive before forcing full GC so the witness cannot root the face. + if mode != "css-removed-no-gc" { + vm.renderer_document_isolate + .clone() + .with_entered_renderer_document_isolate(|isolate| { + isolate.clear_kept_objects(); + isolate.low_memory_notification(); + Ok(()) + }) + .unwrap(); + } + assert_eq!( + vm.eval("faceWitness.deref()===undefined").unwrap(), + "false", + "a retained .loaded promise must keep the loading wrapper alive" + ); + gates.remove("lifetime.woff2").unwrap().send(()).unwrap(); + server.await.unwrap(); + assert!( + tokio::time::timeout( + Duration::from_secs(2), + completions.wait_for_arrival_without_timeout() + ) + .await + .unwrap() + ); + let completion = completions.pop_next_async_subresource_event().unwrap(); + let activity = vm + .complete_async_subresource_fetch_event_body(completion) + .unwrap(); + vm.finish_async_subresource_body_checkpoint_for_test(activity) + .unwrap(); + assert_eq!( + vm._context_host + .borrow() + .pending_subresource_request_count(), + 0 + ); + let results = vm.eval("JSON.stringify(promiseResults)").unwrap(); + assert_eq!( + results, + if failure { + r#"[["error","NetworkError"]]"# + } else { + r#"[["Lifetime","loaded"]]"# + }, + "{mode}: retaining .loaded must preserve its settlement endpoint" + ); + // A fulfilled promise necessarily retains its FontFace result. A rejected + // promise must release the wrapper even while that promise remains live. + if !failure { + vm.eval("globalThis.retainedPromise=null").unwrap(); + } + vm.eval("if(document.styleSheets.length&&document.styleSheets[0].cssRules.length) document.styleSheets[0].deleteRule(0); void document.fonts.size").unwrap(); + run_font_tasks(&mut vm); + vm.renderer_document_isolate + .clone() + .with_entered_renderer_document_isolate(|isolate| { + isolate.clear_kept_objects(); + isolate.low_memory_notification(); + Ok(()) + }) + .unwrap(); + assert_eq!( + vm.eval("faceWitness.deref()===undefined").unwrap(), + "true", + "completed font wrappers must not be kept alive by their resolver" + ); +} + +#[tokio::test] +async fn css_removed_loaded_promise_survives_gc() { + assert_loaded_promise_lifetime("css-removed-gc").await; +} + +#[tokio::test] +async fn css_removed_loaded_promise_without_forced_gc() { + assert_loaded_promise_lifetime("css-removed-no-gc").await; +} + +#[tokio::test] +async fn css_member_loaded_promise_survives_gc() { + assert_loaded_promise_lifetime("css-member").await; +} + +#[tokio::test] +async fn js_only_loaded_promise_survives_gc() { + assert_loaded_promise_lifetime("js-only").await; +} + +#[tokio::test] +async fn loading_handler_ready_cannot_finish_load_before_add() { + let (origin, mut requests, mut gates, server) = gated_fonts(&["slow.woff2"]).await; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let (mut vm, mut completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + &format!("{origin}/page"), + "", + &loader, + ); + vm.eval(&format!(r#"globalThis.fonts=document.fonts; + globalThis.face=new FontFace('Slow','url("{origin}/slow.woff2")'); + globalThis.readyResolved=false;globalThis.doneEvents=[];globalThis.loadingEvents=0; + fonts.addEventListener('loading',()=>{{loadingEvents++;fonts.ready.then(()=>readyResolved=true)}}); + fonts.addEventListener('loadingdone',event=>doneEvents.push(event.fontfaces.length)); + face.load();fonts.add(face);"#)).unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), requests.recv()) + .await + .unwrap() + .unwrap(), + "/slow.woff2" + ); + assert_eq!( + vm.eval("loadingEvents").unwrap(), + "0", + "loading must run as a task after add returns" + ); + run_font_tasks(&mut vm); + assert_eq!(vm.eval("loadingEvents").unwrap(), "1"); + let blocked = vm + .eval("JSON.stringify([face.status,fonts.status,readyResolved,doneEvents])") + .unwrap(); + gates.remove("slow.woff2").unwrap().send(()).unwrap(); + server.await.unwrap(); + assert!( + tokio::time::timeout( + Duration::from_secs(2), + completions.wait_for_arrival_without_timeout() + ) + .await + .unwrap() + ); + let completion = completions.pop_next_async_subresource_event().unwrap(); + let activity = vm + .complete_async_subresource_fetch_event_body(completion) + .unwrap(); + vm.finish_async_subresource_body_checkpoint_for_test(activity) + .unwrap(); + run_font_tasks(&mut vm); + assert_eq!( + vm.eval("JSON.stringify([face.status,readyResolved,doneEvents])") + .unwrap(), + r#"["loaded",true,[1]]"# + ); + assert_eq!(blocked, r#"["loading","loading",false,[]]"#); +} + +#[tokio::test] +async fn css_removed_loaded_promise_rejects_after_gc_and_releases_wrapper() { + assert_loaded_promise_lifetime("css-removed-error").await; +} + +#[tokio::test] +async fn font_set_finish_task_rechecks_fonts_started_before_dispatch() { + let (origin, mut requests, mut gates, server) = gated_fonts(&["slow.woff2"]).await; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let (mut vm, mut completions) = new_parsed_test_vm_with_loader_and_resource_completion_queue( + &format!("{origin}/page"), + "", + &loader, + ); + let immediate = fixture_font_url(); + vm.eval(&format!(r#" + const fonts=document.fonts; + const first=new FontFace('First','url("{immediate}")'); + fonts.add(first);first.load(); + globalThis.firstReady=fonts.ready;globalThis.readyResolved=false; + firstReady.then(()=>readyResolved=true); + globalThis.doneEvents=[]; + fonts.addEventListener('loadingdone',event=>doneEvents.push(event.fontfaces.map(face=>face.family).sort())); + globalThis.slow=new FontFace('Slow','url("{origin}/slow.woff2")'); + fonts.add(slow);slow.load(); + "#)).unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), requests.recv()) + .await + .unwrap() + .unwrap(), + "/slow.woff2" + ); + run_font_tasks(&mut vm); + assert_eq!(vm.eval("JSON.stringify([slow.status,document.fonts.status,firstReady===document.fonts.ready,readyResolved,doneEvents])").unwrap(), r#"["loading","loading",true,false,[]]"#); + gates.remove("slow.woff2").unwrap().send(()).unwrap(); + server.await.unwrap(); + assert!( + tokio::time::timeout( + Duration::from_secs(2), + completions.wait_for_arrival_without_timeout() + ) + .await + .unwrap() + ); + let completion = completions.pop_next_async_subresource_event().unwrap(); + let activity = vm + .complete_async_subresource_fetch_event_body(completion) + .unwrap(); + vm.finish_async_subresource_body_checkpoint_for_test(activity) + .unwrap(); + run_font_tasks(&mut vm); + assert_eq!( + vm.eval("JSON.stringify([readyResolved,doneEvents])") + .unwrap(), + r#"[true,[["First","Slow"]]]"# + ); +} + +#[tokio::test] +async fn font_loading_tasks_preserve_order_and_reentrant_cycle_promises() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut page = new_storage_page_task_executor_test_vm_with_loader( + "https://font-event-tasks.test/", + &loader, + ); + let source = fixture_font_url(); + page.eval(&format!(r#" + globalThis.fontTrace=[];globalThis.secondCycle=false; + const fonts=document.fonts; + fonts.addEventListener('loading',()=>fontTrace.push('loading')); + fonts.addEventListener('loadingerror',event=>fontTrace.push('error:'+event.fontfaces.map(face=>face.family).join(','))); + fonts.addEventListener('loadingdone',event=>{{ + fontTrace.push('done:'+event.fontfaces.map(face=>face.family).join(',')); + if(!secondCycle) {{ + secondCycle=true;fonts.delete(first); + const next=new FontFace('Next','url("{source}")'); + fonts.add(next);next.load(); + const nextReady=fonts.ready; + fontTrace.push('new-cycle:'+(nextReady!==firstReady)+':'+fonts.status); + nextReady.then(()=>fontTrace.push('next-ready')); + }} + }}); + const first=new FontFace('Failed','local("MoliDefinitelyNotAFont-009943")'); + fonts.add(first);first.load().catch(()=>{{}}); + const firstReady=fonts.ready; + firstReady.then(()=>fontTrace.push('first-ready')); + fontTrace.push('script');queueMicrotask(()=>fontTrace.push('microtask')); + "#)).unwrap(); + assert_eq!( + page.eval("JSON.stringify(fontTrace)").unwrap(), + r#"["script","microtask"]"# + ); + advance_page_task_executor_until_eval_equals( + &mut page, + &loader, + "fontTrace.includes('next-ready')", + "true", + "font loading tasks should complete both cycles through the Page dispatcher", + ) + .await; + assert_eq!( + page.eval("JSON.stringify(fontTrace)").unwrap(), + r#"["script","microtask","loading","done:","new-cycle:true:loading","error:Failed","first-ready","loading","done:Next","next-ready"]"# + ); +} + +#[tokio::test] +async fn font_loading_tasks_do_not_dispatch_after_their_window_retires() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut page = new_storage_page_task_executor_test_vm_with_loader( + "https://font-event-retirement.test/", + &loader, + ); + let source = fixture_font_url(); + page.eval(&format!( + r#" + globalThis.fontTrace=[];globalThis.fontTasksDrained=false; + const frame=document.body.appendChild(document.createElement('iframe')); + const child=frame.contentWindow,fonts=child.document.fonts; + for(const type of ['loading','loadingdone','loadingerror']) + fonts.addEventListener(type,()=>fontTrace.push(type)); + const face=new child.FontFace('Retired','url("{source}")'); + fonts.add(face);face.load();frame.remove(); + setTimeout(()=>fontTasksDrained=true,0); + "# + )) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut page, + &loader, + "fontTasksDrained", + "true", + "the surviving Window timer should run after retired font tasks are discarded", + ) + .await; + assert_eq!(page.eval("JSON.stringify(fontTrace)").unwrap(), "[]"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/unicode_ranges.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/unicode_ranges.rs new file mode 100644 index 0000000000..e63e3d5902 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/fonts/unicode_ranges.rs @@ -0,0 +1,81 @@ +use super::*; +fn font_widths(vm: &mut StandaloneScriptVmHarness) -> [f32; 6] { + // Use the page API so this warms the same frozen geometry cache as CDP. + // Building a fresh native pass here would hide missing font invalidation. + let widths = vm + .eval( + r#"JSON.stringify( + ['rangeA','rangeB','allA','allB','fallbackA','fallbackB'].map( + id=>document.getElementById(id).getBoundingClientRect().width))"#, + ) + .unwrap(); + serde_json::from_str(&widths).unwrap() +} + +#[test] +fn font_face_unicode_range_limits_layout_and_updates_after_loading() { + let mut vm = new_parsed_test_vm( + "https://font-unicode-range.test/", + "", + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand); + let font = base64::engine::general_purpose::STANDARD.encode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../moli-layout/tests/fixtures/moli-ahem.woff2" + ))); + vm.eval(&format!( + r#" + const bytes=Uint8Array.from(atob('{font}'),c=>c.charCodeAt(0)); + globalThis.face=new FontFace('RangeFace',bytes,{{unicodeRange:'U+0041'}}); + const all=new FontFace('AllFace',bytes); + document.fonts.add(face);document.fonts.add(all); + for(const [id,family,text] of [ + ['rangeA','RangeFace,monospace','AAAA'],['rangeB','RangeFace,monospace','BBBB'], + ['allA','AllFace,monospace','AAAA'],['allB','AllFace,monospace','BBBB'], + ['fallbackA','monospace','AAAA'],['fallbackB','monospace','BBBB'] + ]) {{ + const span=document.createElement('span');span.id=id;span.textContent=text; + span.style.cssText=`display:inline-block;font-size:40px;font-family:${{family}}`; + document.body.append(span); + }} + "# + )) + .unwrap(); + assert_eq!(vm.eval("face.status").unwrap(), "loaded"); + let [range_a, range_b, all_a, all_b, fallback_a, fallback_b] = font_widths(&mut vm); + assert!( + (all_a - fallback_a).abs() > 0.1, + "A control must distinguish the custom font from fallback" + ); + assert!( + (all_b - fallback_b).abs() > 0.1, + "B control must distinguish the custom font from fallback" + ); + assert_eq!(range_a, all_a); + assert_eq!(range_b, fallback_b); + + vm.eval("face.unicodeRange='U+0042'").unwrap(); + let [range_a, range_b, ..] = font_widths(&mut vm); + assert_eq!(range_a, fallback_a); + assert_eq!(range_b, all_b); + + // Other descriptor writes must not restore the constructor's old range. + let generation = vm._context_host.borrow().visual_state_generation(); + vm.eval("face.weight='400'").unwrap(); + assert_eq!( + vm._context_host.borrow().visual_state_generation(), + generation + ); + let [range_a, range_b, ..] = font_widths(&mut vm); + assert_eq!(range_a, fallback_a); + assert_eq!(range_b, all_b); + + vm.eval("document.fonts.delete(face)").unwrap(); + let [range_a, range_b, ..] = font_widths(&mut vm); + assert_eq!(range_a, fallback_a); + assert_eq!(range_b, fallback_b); + vm.eval("document.fonts.add(face)").unwrap(); + let [range_a, range_b, ..] = font_widths(&mut vm); + assert_eq!(range_a, fallback_a); + assert_eq!(range_b, all_b); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/fonts.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/fonts.rs index bbaeee18a1..36d660e637 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/fonts.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/fonts.rs @@ -205,9 +205,8 @@ fn font_face_set_load_event_copies_and_freezes_fontfaces() { )) .unwrap(); - let result = vm - .eval( - r#" + vm.eval( + r#" (() => { const face = new FontFace('Demo', fixtureFontSource); const source = [face]; @@ -257,7 +256,7 @@ fn font_face_set_load_event_copies_and_freezes_fontfaces() { FontFaceSetLoadEvent.prototype, 'fontfaces' ); - return JSON.stringify({ + globalThis.__fontFaceSetEventProbe = { surface: [ typeof FontFaceSetLoadEvent, FontFaceSetLoadEvent.length, @@ -294,13 +293,17 @@ fn font_face_set_load_event_copies_and_freezes_fontfaces() { throwsTypeError(() => new FontFaceSetLoadEvent('loading', { fontfaces: [{}] })), throwsTypeError(() => new FontFaceSetLoadEvent('loading', { fontfaces: null })) ], - dispatched: [loadingShape, doneShape] - }); + get dispatched() { return [loadingShape, doneShape]; } + }; })() "#, - ) - .expect("FontFaceSetLoadEvent FrozenArray semantics should evaluate"); + ) + .expect("FontFaceSetLoadEvent FrozenArray semantics should evaluate"); + run_font_tasks(&mut vm); + let result = vm + .eval("JSON.stringify(globalThis.__fontFaceSetEventProbe)") + .unwrap(); assert_eq!( result, r#"{"surface":["function",1,true,true,"[object FontFaceSetLoadEvent]"],"empty":[true,0,true,true],"configured":["loadingdone",true,true,true,1,true,true,true],"descriptor":["function","undefined","true","true","false"],"errors":[true,true,true,true],"dispatched":["true:true:true:0","true:true:true:1:true:true"]}"# @@ -475,9 +478,8 @@ fn font_face_load_updates_owner_sets_once_and_unlinks_removed_faces() { )) .unwrap(); - let result = vm - .eval( - r#" + vm.eval( + r#" (() => { const face = new FontFace('Demo', fixtureFontSource); const documentFonts = document.fonts; @@ -528,7 +530,7 @@ fn font_face_load_updates_owner_sets_once_and_unlinks_removed_faces() { const eventCountBeforeClearedLoad = events.length; cleared.load(); - return JSON.stringify({ + globalThis.__fontFaceOwnerProbe = { loads: [firstLoad === face.loaded, secondLoad === face.loaded], ready: [ documentReadyBefore !== documentReadyAfter, @@ -536,7 +538,7 @@ fn font_face_load_updates_owner_sets_once_and_unlinks_removed_faces() { documentReadyAfter === documentFonts.ready, secondaryReadyAfter === secondary.ready ], - status: [documentFonts.status, secondary.status], + get status() { return [documentFonts.status, secondary.status]; }, events, repeated: events.length === eventCountAfterFirstLoad, removed: [ @@ -549,12 +551,16 @@ fn font_face_load_updates_owner_sets_once_and_unlinks_removed_faces() { clearedSet.ready === clearedReadyBeforeLoad, clearedSet.status === 'loaded' ] - }); + }; })() "#, - ) - .expect("FontFace loads should update each current owner set once"); + ) + .expect("FontFace loads should update each current owner set once"); + run_font_tasks(&mut vm); + let result = vm + .eval("JSON.stringify(globalThis.__fontFaceOwnerProbe)") + .unwrap(); assert_eq!( result, r#"{"loads":[true,true],"ready":[true,true,true,true],"status":["loaded","loaded"],"events":["document:loading:true:true:true:0:true:true","secondary:loading:true:true:true:0:true:true","document:loadingdone:true:true:true:1:true:true","secondary:loadingdone:true:true:true:1:true:true"],"repeated":true,"removed":[true,true,true],"cleared":[true,true,true]}"# @@ -572,9 +578,8 @@ fn stylesheet_font_face_load_updates_document_font_set() { )) .unwrap(); - let result = vm - .eval( - r#" + vm.eval( + r#" (() => { const observed = []; let face; @@ -594,18 +599,22 @@ fn stylesheet_font_face_load_updates_document_font_set() { face = [...document.fonts].find(face => face.family === 'StylesheetOwner'); const readyBefore = document.fonts.ready; const loaded = face.load(); - return JSON.stringify({ + globalThis.__stylesheetFontFaceProbe = { face: face instanceof FontFace, loaded: loaded === face.loaded, ready: readyBefore !== document.fonts.ready, - status: document.fonts.status, + get status() { return document.fonts.status; }, observed - }); + }; })() "#, - ) - .expect("stylesheet FontFace loads should update document.fonts"); + ) + .expect("stylesheet FontFace loads should update document.fonts"); + run_font_tasks(&mut vm); + let result = vm + .eval("JSON.stringify(globalThis.__stylesheetFontFaceProbe)") + .unwrap(); assert_eq!( result, r#"{"face":true,"loaded":true,"ready":true,"status":"loaded","observed":["loading:0:true:true","loadingdone:1:true:true"]}"# diff --git a/moli-renderer-v8/src/script_vm/tests/child_document_web_fonts.rs b/moli-renderer-v8/src/script_vm/tests/child_document_web_fonts.rs index 9bf2d019a3..a95d3ca848 100644 --- a/moli-renderer-v8/src/script_vm/tests/child_document_web_fonts.rs +++ b/moli-renderer-v8/src/script_vm/tests/child_document_web_fonts.rs @@ -179,6 +179,8 @@ async fn child_document_fonts_ready_waits_for_its_own_resource() { .unwrap(); vm.perform_owner_lane_task_microtask_checkpoints().unwrap(); refresh_child_geometry_without_paint(&vm); + assert_eq!(vm.eval("childReady").unwrap(), "false"); + run_font_tasks(&mut vm); assert_eq!(vm.eval("JSON.stringify([rootReady,childReady,child.document.fonts.status,childTarget.offsetWidth,childFace.status,childFaceLoaded])").unwrap(), r#"[true,true,"loaded",48,"loaded",true]"#); assert_eq!(vm.eval("String(parentTarget.offsetWidth)").unwrap(), parent); assert_eq!(vm.document_web_font_counts_for_test(), (0, 0, 0)); diff --git a/moli-renderer-v8/src/script_vm/tests/extracted/resources_and_media.rs b/moli-renderer-v8/src/script_vm/tests/extracted/resources_and_media.rs index 002e2b5fb3..3731e6efb1 100644 --- a/moli-renderer-v8/src/script_vm/tests/extracted/resources_and_media.rs +++ b/moli-renderer-v8/src/script_vm/tests/extracted/resources_and_media.rs @@ -55,6 +55,8 @@ async fn document_fonts_ready_waits_for_shadow_font_and_refreshes_cached_geometr vm.perform_owner_lane_task_microtask_checkpoints() .expect("font completion checkpoint"); assert_eq!(vm.document_web_font_counts_for_test(), (1, 1, 1)); + assert_eq!(vm.eval("fontReadyResolved").unwrap(), "false"); + run_font_tasks(&mut vm); assert_eq!(vm.eval("JSON.stringify([fontReadyResolved,document.fonts.status,fontProbe.getBoundingClientRect().width])").unwrap(),r#"[true,"loaded",48]"#); } diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 21f4ffae16..38967a5190 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -565,6 +565,19 @@ fn refresh_layout_for_test(vm: &mut StandaloneScriptVmHarness) { ); } +// These font domain tests supply network bodies directly. Run their queued +// event tasks and checkpoints explicitly; Page dispatcher behavior is covered +// separately by the font event ordering and Window retirement tests. +fn run_font_tasks(vm: &mut ScriptVm) { + for _ in 0..16 { + if !vm.has_ready_timeout() { + return; + } + assert!(vm.run_next_timeout_for_test().unwrap().consumed_heap_head()); + } + panic!("font loading tasks did not become idle"); +} + fn publish_layout_for_test(vm: &mut StandaloneScriptVmHarness) { vm.publish_layout_for_test() .expect("fixture screenshot should succeed"); diff --git a/moli-test-support/src/lib.rs b/moli-test-support/src/lib.rs index c05112a3b3..313e39f11d 100644 --- a/moli-test-support/src/lib.rs +++ b/moli-test-support/src/lib.rs @@ -29,6 +29,8 @@ struct FixtureRuntimeState { dynamic_stylesheet_script_executed: Arc, runtime_inline_module_executed: Arc, alternate_stylesheet_probe_executed: Arc, + runtime_owned_async_script_executed: Arc, + dynamic_preparation_context_replaced: Arc, } #[derive(Default)] @@ -1425,7 +1427,7 @@ const DYNAMIC_TAXONOMY_ASYNC_FAST_JS: &str = "window.dynamicScriptTaxonomyOrder. const DYNAMIC_TAXONOMY_IN_ORDER_SLOW_JS: &str = "if (window.dynamicScriptTaxonomyOrder) { window.dynamicScriptTaxonomyOrder.push('in-order-slow'); window.dynamicScriptTaxonomyOrderResult = window.dynamicScriptTaxonomyOrder.join(','); } if (window.dynamicInOrderScriptOrder) { window.dynamicInOrderScriptOrder.push('in-order-slow'); window.dynamicInOrderScriptOrderResult = window.dynamicInOrderScriptOrder.join(','); }"; const DYNAMIC_TAXONOMY_IN_ORDER_FAST_JS: &str = "window.dynamicInOrderScriptOrder.push('in-order-fast'); window.dynamicInOrderScriptOrderResult = window.dynamicInOrderScriptOrder.join(',');"; const DYNAMIC_PREPARATION_CONTEXT_STALE_JS: &str = "window.staleDynamicRan = true; window.dynamicPreparationContextOrder = window.dynamicPreparationContextOrder || []; window.dynamicPreparationContextOrder.push('stale'); window.dynamicPreparationContextResult = window.dynamicPreparationContextOrder.join(',');"; -const DYNAMIC_PREPARATION_CONTEXT_OPEN_JS: &str = r#"document.open(); document.write(``); document.close();"#; +const DYNAMIC_PREPARATION_CONTEXT_OPEN_JS: &str = r#"document.open(); document.write(``); document.close();"#; const DOCUMENT_WRITE_IMPLICIT_REPLACE_ASYNC_JS: &str = r#"window.documentWriteImplicitOrder.push('dynamic'); document.write(`
new
`);"#; const DOCUMENT_WRITE_IMPLICIT_REPLACE_ASYNC_MODULE_JS: &str = r#"window.documentWriteImplicitModuleOrder.push('dynamic'); document.write(`
new
`);"#; const DOCUMENT_WRITE_IMPLICIT_REPLACE_STALE_DEFER_JS: &str = "window.documentWriteImplicitOrder.push('stale-defer'); window.documentWriteImplicitStaleDeferRan = true; window.documentWriteImplicitOrderResult = window.documentWriteImplicitOrder.join(',');"; diff --git a/moli-test-support/src/routes_core.rs b/moli-test-support/src/routes_core.rs index 0b0bd16957..a168ea1208 100644 --- a/moli-test-support/src/routes_core.rs +++ b/moli-test-support/src/routes_core.rs @@ -12,9 +12,6 @@ static PARSER_IMAGE_FETCH_POLICY_ASSET_REQUESTS: OnceLock>>, > = OnceLock::new(); -static RUNTIME_OWNED_ASYNC_CHUNKED_TAIL_GATES: OnceLock< - Mutex>>, -> = OnceLock::new(); static RUNTIME_OWNED_IN_ORDER_ERROR_AFTER_DCL_GATES: OnceLock< Mutex>>, > = OnceLock::new(); @@ -98,35 +95,6 @@ fn notify_parse_time_async_chunked_tail_gate_if_present(host_key: &str) { } } -fn runtime_owned_async_chunked_tail_gate(host_key: &str) -> Arc { - let gates = RUNTIME_OWNED_ASYNC_CHUNKED_TAIL_GATES.get_or_init(|| Mutex::new(HashMap::new())); - let mut gates = gates.lock(); - gates - .entry(host_key.to_owned()) - .or_insert_with(|| Arc::new(tokio::sync::Notify::new())) - .clone() -} - -fn remove_runtime_owned_async_chunked_tail_gate(host_key: &str) { - let Some(gates) = RUNTIME_OWNED_ASYNC_CHUNKED_TAIL_GATES.get() else { - return; - }; - gates.lock().remove(host_key); -} - -fn notify_runtime_owned_async_chunked_tail_gate_if_present(host_key: &str) { - let Some(gates) = RUNTIME_OWNED_ASYNC_CHUNKED_TAIL_GATES.get() else { - return; - }; - let notify = { - let gates = gates.lock(); - gates.get(host_key).cloned() - }; - if let Some(notify) = notify { - notify.notify_one(); - } -} - fn runtime_owned_in_order_error_after_dcl_gate(host_key: &str) -> Arc { let gates = RUNTIME_OWNED_IN_ORDER_ERROR_AFTER_DCL_GATES.get_or_init(|| Mutex::new(HashMap::new())); @@ -2617,7 +2585,7 @@ pub(super) async fn runtime_owned_external_async_fast_does_not_overtake_domconte } pub(super) async fn runtime_owned_external_async_fast_streaming_tail_page( - headers: HeaderMap, + Extension(state): Extension, ) -> Response { let split_marker = "
"; let split_index = RUNTIME_OWNED_EXTERNAL_ASYNC_FAST_DOES_NOT_OVERTAKE_DOMCONTENTLOADED_HTML @@ -2625,30 +2593,42 @@ pub(super) async fn runtime_owned_external_async_fast_streaming_tail_page( .expect("streaming runtime-owned async fixture must include late DOM marker"); let (head, tail) = RUNTIME_OWNED_EXTERNAL_ASYNC_FAST_DOES_NOT_OVERTAKE_DOMCONTENTLOADED_HTML .split_at(split_index); - let head = head.as_bytes().to_vec(); + let head = head + .replace( + "", + "", + ) + .into_bytes(); let tail = tail.as_bytes().to_vec(); - let host_key = request_host_key(&headers).unwrap_or_default(); - let tail_gate = runtime_owned_async_chunked_tail_gate(&host_key); let (tx, rx) = tokio::sync::mpsc::channel(4); tokio::spawn(async move { if tx - .send(Ok::(Bytes::from(head))) + .send(Ok::(Bytes::from(head))) .await .is_err() { return; } - let _ = tokio::time::timeout(Duration::from_millis(500), tail_gate.notified()).await; - // Keep the parser tail unavailable until the already-requested script - // can complete. Chromium then permits this dynamic async task while - // `document.readyState` is still `loading`. - sleep(Duration::from_millis(20)).await; - let _ = tx - .send(Ok::(Bytes::from(tail))) - .await; - if !host_key.is_empty() { - remove_runtime_owned_async_chunked_tail_gate(&host_key); + // Wait for execution, not merely the script request arriving. A slow + // renderer must not let the parser tail overtake the async task. + if tokio::time::timeout( + Duration::from_secs(5), + state.runtime_owned_async_script_executed.wait(), + ) + .await + .is_err() + { + let _ = tx + .send(Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "async script did not release the parser tail", + ))) + .await; + return; } + let _ = tx + .send(Ok::(Bytes::from(tail))) + .await; }); Response::builder() @@ -4429,13 +4409,17 @@ pub(super) async fn asset_runtime_owned_async_slow_script() -> Response { javascript_response(RUNTIME_OWNED_ASYNC_SLOW_JS) } -pub(super) async fn asset_runtime_owned_async_fast_script(headers: HeaderMap) -> Response { - if let Some(host_key) = request_host_key(&headers) { - notify_runtime_owned_async_chunked_tail_gate_if_present(&host_key); - } +pub(super) async fn asset_runtime_owned_async_fast_script() -> Response { javascript_response(RUNTIME_OWNED_ASYNC_FAST_JS) } +pub(super) async fn asset_runtime_owned_async_fast_executed( + Extension(state): Extension, +) -> StatusCode { + state.runtime_owned_async_script_executed.signal(); + StatusCode::NO_CONTENT +} + pub(super) async fn asset_runtime_owned_default_async_module_side_effect_module() -> Response { javascript_response(RUNTIME_OWNED_DEFAULT_ASYNC_MODULE_SIDE_EFFECT_MJS) } @@ -4642,11 +4626,32 @@ pub(super) async fn asset_document_write_inserted_chunked_external_script() -> R javascript_response(DOCUMENT_WRITE_INSERTED_CHUNKED_EXTERNAL_JS) } -pub(super) async fn asset_dynamic_preparation_context_stale_script() -> Response { - sleep(Duration::from_millis(40)).await; +pub(super) async fn asset_dynamic_preparation_context_stale_script( + Extension(state): Extension, +) -> Response { + if tokio::time::timeout( + Duration::from_secs(5), + state.dynamic_preparation_context_replaced.wait(), + ) + .await + .is_err() + { + return ( + StatusCode::GATEWAY_TIMEOUT, + "replacement document did not release the stale script", + ) + .into_response(); + } javascript_response(DYNAMIC_PREPARATION_CONTEXT_STALE_JS) } +pub(super) async fn asset_dynamic_preparation_context_replaced( + Extension(state): Extension, +) -> StatusCode { + state.dynamic_preparation_context_replaced.signal(); + StatusCode::NO_CONTENT +} + pub(super) async fn asset_dynamic_preparation_context_open_script() -> Response { javascript_response(DYNAMIC_PREPARATION_CONTEXT_OPEN_JS) } diff --git a/moli-test-support/src/server_routes.rs b/moli-test-support/src/server_routes.rs index 7a6c387c78..74e9e29bba 100644 --- a/moli-test-support/src/server_routes.rs +++ b/moli-test-support/src/server_routes.rs @@ -2314,6 +2314,14 @@ pub(super) fn build_router() -> Router { "/assets/dynamic_preparation_context_open.js", get(asset_dynamic_preparation_context_open_script), ) + .route( + "/assets/dynamic_preparation_context_replaced", + get(asset_dynamic_preparation_context_replaced), + ) + .route( + "/assets/runtime_owned_async_fast_executed", + get(asset_runtime_owned_async_fast_executed), + ) .route( "/assets/document_write_implicit_replace_async.js", get(asset_document_write_implicit_replace_async_script),