fix(fonts): defer set events and preserve ranges and promises

This commit is contained in:
ldm0
2026-10-23 04:07:15 +08:00
committed by Donough Liu
parent 98543feea9
commit ff15becb62
22 changed files with 801 additions and 102 deletions
@@ -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 = [];
@@ -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);
+5 -1
View File
@@ -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!(
+24 -3
View File
@@ -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()
+30
View File
@@ -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(
@@ -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::<v8::Array>::try_from(args.data()).expect("font loading task data");
let owner = data
.get_index(scope, 0)
.and_then(|value| v8::Local::<v8::Object>::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::<v8::Array>::try_from(v).ok());
set_private_value(scope, owner, slot, v8::undefined(scope).into());
(faces, event)
});
set_font_face_set_status(scope, owner, "loaded");
@@ -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);
}
}
@@ -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);
}
+1 -1
View File
@@ -301,7 +301,7 @@ fn parsed_web_font_face(css_text: &str) -> Option<ParsedWebFontFace> {
})
}
fn parse_font_unicode_ranges(value: &str) -> Option<Vec<WebFontUnicodeRange>> {
pub(crate) fn parse_font_unicode_ranges(value: &str) -> Option<Vec<WebFontUnicodeRange>> {
let input = value;
let mut input = Parser::new(input);
input
+7 -4
View File
@@ -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)
}
}
@@ -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,
@@ -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)?;
@@ -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"]"#
@@ -11,6 +11,25 @@ async fn gated_fonts(
tokio::sync::mpsc::UnboundedReceiver<String>,
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<String>,
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"),
"<!doctype html><html><head></head><body>AAAA</body></html>",
&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"),
"<!doctype html><html><head></head><body></body></html>",
&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"),
"<!doctype html><html><head></head><body></body></html>",
&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(), "[]");
}
@@ -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/",
"<!doctype html><html><head></head><body></body></html>",
);
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);
}
@@ -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"]}"#
@@ -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));
@@ -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]"#);
}
@@ -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");
+3 -1
View File
@@ -29,6 +29,8 @@ struct FixtureRuntimeState {
dynamic_stylesheet_script_executed: Arc<FixtureEvent>,
runtime_inline_module_executed: Arc<FixtureEvent>,
alternate_stylesheet_probe_executed: Arc<FixtureEvent>,
runtime_owned_async_script_executed: Arc<FixtureEvent>,
dynamic_preparation_context_replaced: Arc<FixtureEvent>,
}
#[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(`<!doctype html><html><body><script>window.documentOpenAsyncRan = true; window.dynamicPreparationContextOrder = ['replacement']; window.dynamicPreparationContextResult = 'pending'; setTimeout(() => { window.dynamicPreparationContextResult = window.staleDynamicRan ? 'stale-ran' : 'replacement-only'; }, 0);</script></body></html>`); document.close();"#;
const DYNAMIC_PREPARATION_CONTEXT_OPEN_JS: &str = r#"document.open(); document.write(`<!doctype html><html><body><script>window.documentOpenAsyncRan = true; fetch('/assets/dynamic_preparation_context_replaced'); window.dynamicPreparationContextOrder = ['replacement']; window.dynamicPreparationContextResult = 'pending'; setTimeout(() => { window.dynamicPreparationContextResult = window.staleDynamicRan ? 'stale-ran' : 'replacement-only'; }, 0);</script></body></html>`); document.close();"#;
const DOCUMENT_WRITE_IMPLICIT_REPLACE_ASYNC_JS: &str = r#"window.documentWriteImplicitOrder.push('dynamic'); document.write(`<!doctype html><html><body><main id="new">new</main><script>window.documentWriteImplicitOrder.push('replacement'); window.documentWriteImplicitReplaceRan = true; window.documentWriteImplicitResult = 'pending'; window.documentWriteImplicitOrderResult = window.documentWriteImplicitOrder.join(','); setTimeout(() => { window.documentWriteImplicitResult = window.documentWriteImplicitStaleDeferRan ? 'stale-ran' : 'replacement-only'; window.documentWriteImplicitOrderResult = window.documentWriteImplicitOrder.join(','); }, 0);</script></body></html>`);"#;
const DOCUMENT_WRITE_IMPLICIT_REPLACE_ASYNC_MODULE_JS: &str = r#"window.documentWriteImplicitModuleOrder.push('dynamic'); document.write(`<!doctype html><html><body><main id="new">new</main><script>window.documentWriteImplicitModuleOrder.push('replacement'); window.documentWriteImplicitModuleReplaceRan = true; window.documentWriteImplicitModuleResult = 'pending'; window.documentWriteImplicitModuleOrderResult = window.documentWriteImplicitModuleOrder.join(','); setTimeout(() => { window.documentWriteImplicitModuleResult = window.documentWriteImplicitStaleModuleRan ? 'stale-ran' : 'replacement-only'; window.documentWriteImplicitModuleOrderResult = window.documentWriteImplicitModuleOrder.join(','); }, 0);</script></body></html>`);"#;
const DOCUMENT_WRITE_IMPLICIT_REPLACE_STALE_DEFER_JS: &str = "window.documentWriteImplicitOrder.push('stale-defer'); window.documentWriteImplicitStaleDeferRan = true; window.documentWriteImplicitOrderResult = window.documentWriteImplicitOrder.join(',');";
+58 -53
View File
@@ -12,9 +12,6 @@ static PARSER_IMAGE_FETCH_POLICY_ASSET_REQUESTS: OnceLock<Mutex<HashMap<(String,
static PARSE_TIME_ASYNC_CHUNKED_TAIL_GATES: OnceLock<
Mutex<HashMap<String, Arc<tokio::sync::Notify>>>,
> = OnceLock::new();
static RUNTIME_OWNED_ASYNC_CHUNKED_TAIL_GATES: OnceLock<
Mutex<HashMap<String, Arc<tokio::sync::Notify>>>,
> = OnceLock::new();
static RUNTIME_OWNED_IN_ORDER_ERROR_AFTER_DCL_GATES: OnceLock<
Mutex<HashMap<String, Arc<tokio::sync::Notify>>>,
> = 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<tokio::sync::Notify> {
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<tokio::sync::Notify> {
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<FixtureRuntimeState>,
) -> Response {
let split_marker = "<main id=\"late\">";
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(
"<head>",
"<head><script>window.runtimeOwnedAsyncFastTailGated = true;</script>",
)
.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, std::convert::Infallible>(Bytes::from(head)))
.send(Ok::<Bytes, std::io::Error>(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, std::convert::Infallible>(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, std::io::Error>(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<FixtureRuntimeState>,
) -> 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<FixtureRuntimeState>,
) -> 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<FixtureRuntimeState>,
) -> 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)
}
+8
View File
@@ -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),