diff --git a/moli-core/tests/fixtures/preload-consumption-style.js b/moli-core/tests/fixtures/preload-consumption-style.js index ca452ab21c..880624c135 100644 --- a/moli-core/tests/fixtures/preload-consumption-style.js +++ b/moli-core/tests/fixtures/preload-consumption-style.js @@ -6,6 +6,8 @@ async function () { async function run({cache, phase, cors = null, crossOrigin = false, name = 'basic', preloadIntegrity = '', consumerIntegrity = '', expectedEvents = ['load', 'load'], expectedRequests = 1}) { + // Control fetches also consume the resource timing buffer; isolate each scenario. + performance.clearResourceTimings(); const token = 'style-' + rows.length; const url = new URL('/probe-asset', location.href); Object.entries({token, as: 'style', cache}).forEach(([key, value]) => url.searchParams.set(key, value)); diff --git a/moli-core/tests/fixtures/preload-consumption.js b/moli-core/tests/fixtures/preload-consumption.js index b0b91b4f2d..65de19a260 100644 --- a/moli-core/tests/fixtures/preload-consumption.js +++ b/moli-core/tests/fixtures/preload-consumption.js @@ -3,6 +3,8 @@ async function () { const rows=[]; for (const as of ['script','image']) for (const cache of ['max-age=60','no-store']) for (const phase of ['pending','complete']) for (const cors of [null,'anonymous','use-credentials']) { + // Control fetches also consume the resource timing buffer; isolate each scenario. + performance.clearResourceTimings(); const token=String(rows.length), url=new URL('/probe-asset',location.href); url.searchParams.set('token',token);url.searchParams.set('as',as);url.searchParams.set('cache',cache); const link=document.createElement('link');Object.assign(link,{rel:'preload',as,href:url.href}); @@ -29,6 +31,7 @@ async function () { const rows=[]; for (const cache of ['max-age=60','no-store']) for (const phase of ['pending','complete']) for (const [name,preloadIntegrity,consumerIntegrity,preloadEvent,consumerEvent,requests] of [["good-empty", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "", "load", "load", 1], ["bad-empty", "sha384-AAAA", "", "error", "error", 1], ["bad-same", "sha384-AAAA", "sha384-AAAA", "error", "error", 1], ["bad-good", "sha384-AAAA", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "error", "load", 2], ["good-bad", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha384-AAAA", "load", "error", 2], ["good-options", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ?ignored=option", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "load", "load", 1], ["good-different-algorithm", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha256-cRlncoZ/tNV1cbKTR2cXiKEwVFkjvOz4kToIRLKBiSE=", "load", "load", 2], ["empty-good", "", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "load", "load", 2], ["malformed-empty", "sha384-AAAA===", "", "error", "error", 1], ["good-malformed", "sha384-l+rtjjQWlmlLFL2PC8iIPwnXbiW45bZ6LU1ERjlAxYKUFswREZzY3pPyoT0RsvxJ", "sha384-A=AAA", "load", "error", 2]]) { + performance.clearResourceTimings(); const token='sri-'+rows.length,url=new URL('/probe-asset',location.href); url.searchParams.set('token',token);url.searchParams.set('as','script');url.searchParams.set('cache',cache); const link=document.createElement('link');Object.assign(link,{rel:'preload',as:'script',href:url.href,integrity:preloadIntegrity}); diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index bbb2fa57eb..e70e3f0bdb 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -435,8 +435,9 @@ pub(crate) use self::notification_runtime::{ notification_options_payload, }; pub(crate) use self::performance_runtime::{ - ResourcePerformanceEntry, bind_window_performance_seed, current_performance_time_origin, - increment_performance_event_count, record_performance_dom_content_loaded_event_end, + FetchResourceTiming, ResourcePerformanceEntry, bind_window_performance_seed, + current_performance_time_origin, increment_performance_event_count, + record_performance_dom_content_loaded_event_end, record_performance_dom_content_loaded_event_start, record_performance_load_event_end, record_performance_load_event_end_for_window, record_performance_load_event_start, record_performance_load_event_start_for_window, record_resource_performance_entry, diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs index e337a011ec..944540bae8 100644 --- a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs @@ -2,6 +2,8 @@ use super::*; use crate::util::{get_private_value, set_private_value}; mod entries; +mod fetch_timing; +pub(crate) use fetch_timing::FetchResourceTiming; mod install; mod lazy_subobjects; mod marks_measures; @@ -247,6 +249,9 @@ pub(crate) struct ResourcePerformanceEntry { name: String, initiator_type: String, start_unix_millis: Option, + end_unix_millis: Option, + response_start_unix_millis: Option, + next_hop_protocol: String, transfer_size: f64, encoded_body_size: f64, decoded_body_size: f64, @@ -366,6 +371,9 @@ impl ResourcePerformanceEntry { name: name.into(), initiator_type: initiator_type.to_owned(), start_unix_millis, + end_unix_millis: None, + response_start_unix_millis: None, + next_hop_protocol: String::new(), transfer_size, encoded_body_size: body_size, decoded_body_size: body_size, @@ -413,6 +421,9 @@ impl ResourcePerformanceEntry { name: name.into(), initiator_type: initiator_type.to_owned(), start_unix_millis, + end_unix_millis: None, + response_start_unix_millis: None, + next_hop_protocol: String::new(), transfer_size: 0.0, encoded_body_size: 0.0, decoded_body_size: 0.0, @@ -445,10 +456,25 @@ fn append_resource_performance_entry<'s>( performance: v8::Local<'s, v8::Object>, entry: ResourcePerformanceEntry, ) { - let start_time = entry.start_unix_millis.unwrap_or_else(unix_epoch_millis) - - performance_slot_number(scope, performance, PERFORMANCE_TIME_ORIGIN_SLOT).unwrap_or(0.0); - let resource = - entries::create_performance_entry(scope, "resource", &entry.name, start_time, 0.0, None); + let time_origin = + performance_slot_number(scope, performance, PERFORMANCE_TIME_ORIGIN_SLOT).unwrap_or(0.0); + let start_time = entry.start_unix_millis.unwrap_or_else(unix_epoch_millis) - time_origin; + let start_time = if entry.end_unix_millis.is_some() { + moli_time::coarsened_dom_time_millis(start_time.max(0.0)) + } else { + start_time + }; + let end_time = entry + .end_unix_millis + .map(|end| moli_time::coarsened_dom_time_millis(end - time_origin).max(start_time)); + let resource = entries::create_performance_entry( + scope, + "resource", + &entry.name, + start_time, + end_time.map_or(0.0, |end| (end - start_time).max(0.0)), + None, + ); initialize_resource_timing_slots( scope, resource, @@ -460,6 +486,38 @@ fn append_resource_performance_entry<'s>( entry.response_status, &entry.content_type, ); + if let Some(end_time) = end_time { + set_performance_entry_slot_number( + scope, + resource, + PERFORMANCE_RESOURCE_FETCH_START_SLOT, + start_time, + ); + set_performance_entry_slot_number( + scope, + resource, + PERFORMANCE_RESOURCE_RESPONSE_END_SLOT, + end_time, + ); + if let Some(response_start) = entry.response_start_unix_millis { + let response_start = moli_time::coarsened_dom_time_millis(response_start - time_origin) + .clamp(start_time, end_time); + set_performance_entry_slot_number( + scope, + resource, + PERFORMANCE_RESOURCE_RESPONSE_START_SLOT, + response_start, + ); + } + if let Some(protocol) = v8_string(scope, &entry.next_hop_protocol) { + set_private_value( + scope, + resource, + PERFORMANCE_RESOURCE_NEXT_HOP_PROTOCOL_SLOT, + protocol.into(), + ); + } + } push_performance_entry(scope, performance, resource); } diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs new file mode 100644 index 0000000000..fd7f9cb9b2 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs @@ -0,0 +1,121 @@ +use std::time::Instant; + +use moli_fetch::{FetchResponseFilter, FetchUrlList, ResponseCacheState, ResponseHead}; +use moli_url::WebOrigin; +use url::Url; + +use super::ResourcePerformanceEntry; + +/// State owned by a fetch, from registration through its body terminal. Reading +/// or cloning a JavaScript Response does not create another timing record. +pub(crate) struct FetchResourceTiming { + started: Instant, + start_unix_millis: f64, + response_start_unix_millis: Option, +} + +impl FetchResourceTiming { + pub(crate) fn new() -> Self { + Self { + started: Instant::now(), + start_unix_millis: moli_time::unix_epoch_millis(), + response_start_unix_millis: None, + } + } + + fn now(&self) -> f64 { + self.start_unix_millis + self.started.elapsed().as_secs_f64() * 1000.0 + } + + pub(crate) fn response_started(&mut self) { + self.response_start_unix_millis = Some(self.now()); + } + + pub(crate) fn failure(&self, name: &Url) -> ResourcePerformanceEntry { + let mut entry = ResourcePerformanceEntry::from_network_failure( + name.as_str(), + "fetch", + Some(self.start_unix_millis), + ); + entry.end_unix_millis = Some(self.now()); + entry + } + + pub(crate) fn response( + &self, + name: &Url, + origin: &WebOrigin, + head: &ResponseHead, + filter: &FetchResponseFilter, + body_size: usize, + ) -> ResourcePerformanceEntry { + let readable = filter.is_readable(); + let mut entry = ResourcePerformanceEntry::from_streaming_network_response( + name.as_str(), + "fetch", + Some(self.start_unix_millis), + head, + if readable { body_size } else { 0 }, + ); + entry.end_unix_millis = Some(self.now()); + if !readable { + entry.response_status = 0.0; + entry.content_type.clear(); + } + // CORS controls response body information; TAO separately controls + // connection timing, transfer size, and the cache state. + let allowed = timing_allow_passes(origin, head); + entry.transfer_size = if allowed { + match head.cache_state { + ResponseCacheState::Local => 0.0, + ResponseCacheState::Validated => 300.0, + ResponseCacheState::None => entry.encoded_body_size + 300.0, + } + } else { + 0.0 + }; + if allowed { + entry.response_start_unix_millis = self.response_start_unix_millis; + entry.next_hop_protocol = head + .negotiated_http_version + .map(|version| version.protocol_name().to_owned()) + .unwrap_or_default(); + } + entry + } +} + +fn timing_allow_passes(origin: &WebOrigin, head: &ResponseHead) -> bool { + // Evaluate each response with its own URL-list prefix. Crossing origins + // can taint both the response and the serialized request origin, even when + // the final response returns to the initiating origin. + for (index, redirect) in head.redirect_chain.iter().enumerate() { + if !response_allows_timing( + origin, + FetchUrlList::new(&redirect.from_url, &head.redirect_chain[..index]), + &redirect.headers, + ) { + return false; + } + } + response_allows_timing(origin, head.url_list(), &head.headers) +} + +fn response_allows_timing( + origin: &WebOrigin, + urls: FetchUrlList<'_>, + headers: &[(String, Vec)], +) -> bool { + if !urls.has_cross_origin_url(origin) { + return true; + } + let serialized_origin = urls.serialized_origin(origin); + let value = headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("timing-allow-origin")) + .map(|(_, value)| moli_fetch::decode_header_value(value)) + .collect::>() + .join(", "); + moli_fetch::split_http_header_list(&value) + .any(|value| value == "*" || value == serialized_origin) +} diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime/window_state/pending.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime/window_state/pending.rs index ea19dab855..ef4b464322 100644 --- a/moli-renderer-v8/src/context_bootstrap/performance_runtime/window_state/pending.rs +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime/window_state/pending.rs @@ -68,7 +68,7 @@ pub(in crate::context_bootstrap::performance_runtime) fn queue_pending_resource_ ) { let global = scope.get_current_context().global(scope); let entries = ensure_private_array(scope, global, WINDOW_PERFORMANCE_PENDING_RESOURCES_SLOT, 0); - let record = v8::Array::new(scope, 9); + let record = v8::Array::new(scope, 12); let Some(name) = v8_string(scope, &entry.name) else { return; }; @@ -81,6 +81,9 @@ pub(in crate::context_bootstrap::performance_runtime) fn queue_pending_resource_ let Some(content_type) = v8_string(scope, &entry.content_type) else { return; }; + let Some(next_hop_protocol) = v8_string(scope, &entry.next_hop_protocol) else { + return; + }; let start_time: v8::Local<'_, v8::Value> = entry .start_unix_millis .map(|value| v8::Number::new(scope, value).into()) @@ -95,6 +98,15 @@ pub(in crate::context_bootstrap::performance_runtime) fn queue_pending_resource_ render_blocking_status.into(), v8::Number::new(scope, entry.response_status).into(), content_type.into(), + entry + .end_unix_millis + .map(|value| v8::Number::new(scope, value).into()) + .unwrap_or_else(|| v8::undefined(scope).into()), + entry + .response_start_unix_millis + .map(|value| v8::Number::new(scope, value).into()) + .unwrap_or_else(|| v8::undefined(scope).into()), + next_hop_protocol.into(), ]; for (index, value) in fields.into_iter().enumerate() { let _ = record.set_index(scope, index as u32, value); @@ -130,6 +142,15 @@ pub(in crate::context_bootstrap::performance_runtime) fn take_pending_resource_e render_blocking_status: array_string(scope, record, 6)?, response_status: array_number(scope, record, 7)?, content_type: array_string(scope, record, 8)?, + end_unix_millis: record + .get_index(scope, 9) + .filter(|value| !value.is_undefined()) + .and_then(|value| value.number_value(scope)), + response_start_unix_millis: record + .get_index(scope, 10) + .filter(|value| !value.is_undefined()) + .and_then(|value| value.number_value(scope)), + next_hop_protocol: array_string(scope, record, 11)?, }) }) .collect() diff --git a/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs b/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs index 9386f589f9..b5e4d0ae5f 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/resource_loading.rs @@ -2040,20 +2040,23 @@ impl JsContextHost { pub(crate) fn cancel_streaming_subresource_body_source( &mut self, body_source_id: NetworkBodySourceId, - ) -> bool { - let Some(internal_id) = + ) -> Option<( + v8::Global, + crate::context_bootstrap::ResourcePerformanceEntry, + )> { + let internal_id = self.streaming_subresource_fetches .iter() .find_map(|(internal_id, state)| { (state.body_source_id == body_source_id).then_some(*internal_id) - }) - else { - return false; - }; + })?; let streaming = self .streaming_subresource_fetches .remove(&internal_id) .expect("streaming subresource id was selected from the same map"); + let timing = (!streaming.head.preload_state.is_consumed()) + .then(|| aborted_fetch_timing(&streaming.pending)) + .flatten(); streaming.pending.load.cancel(); self.browser_context_runtime .abort_service_worker_fetch(internal_id); @@ -2064,7 +2067,7 @@ impl JsContextHost { self.record_pending_subresource_continue_event( PendingSubresourceContinueEvent::Completed { internal_id }, ); - true + timing } fn record_streaming_subresource_fetch_failure( @@ -2997,8 +3000,12 @@ impl JsContextHost { internal_id: u64, reason: v8::Local<'s, v8::Value>, reason_payload: Option, - ) -> bool { + ) -> Option<( + v8::Global, + crate::context_bootstrap::ResourcePerformanceEntry, + )> { if let Some(pending) = self.pending_subresource_fetches.remove(&internal_id) { + let timing = aborted_fetch_timing(&pending); pending.load.cancel(); self.browser_context_runtime .abort_service_worker_fetch_with_reason(internal_id, reason_payload.clone()); @@ -3006,10 +3013,11 @@ impl JsContextHost { self.record_pending_subresource_continue_event( PendingSubresourceContinueEvent::Completed { internal_id }, ); - return true; + return timing; } if let Some(running) = self.running_subresource_fetches.remove(&internal_id) { + let timing = aborted_fetch_timing(&running.pending); running.pending.load.cancel(); self.browser_context_runtime .abort_service_worker_fetch_with_reason(internal_id, reason_payload.clone()); @@ -3018,10 +3026,13 @@ impl JsContextHost { self.record_pending_subresource_continue_event( PendingSubresourceContinueEvent::Completed { internal_id }, ); - return true; + return timing; } if let Some(streaming) = self.streaming_subresource_fetches.remove(&internal_id) { + let timing = (!streaming.head.preload_state.is_consumed()) + .then(|| aborted_fetch_timing(&streaming.pending)) + .flatten(); streaming.pending.load.cancel(); self.browser_context_runtime .abort_service_worker_fetch_with_reason(internal_id, reason_payload.clone()); @@ -3041,11 +3052,14 @@ impl JsContextHost { self.record_pending_subresource_continue_event( PendingSubresourceContinueEvent::Completed { internal_id }, ); - return true; + return timing; } if let Some(auth) = self.pending_subresource_auths.remove(&internal_id) { let pending = auth.pending; + let timing = (!auth.response.preload_state.is_consumed()) + .then(|| aborted_fetch_timing(&pending)) + .flatten(); pending.load.cancel(); self.browser_context_runtime .abort_service_worker_fetch_with_reason(internal_id, reason_payload.clone()); @@ -3053,11 +3067,14 @@ impl JsContextHost { self.record_pending_subresource_continue_event( PendingSubresourceContinueEvent::Completed { internal_id }, ); - return true; + return timing; } if let Some(response) = self.pending_subresource_responses.remove(&internal_id) { let pending = response.pending; + let timing = (!response.response.preload_state.is_consumed()) + .then(|| aborted_fetch_timing(&pending)) + .flatten(); pending.load.cancel(); self.browser_context_runtime .abort_service_worker_fetch_with_reason(internal_id, reason_payload); @@ -3065,10 +3082,10 @@ impl JsContextHost { self.record_pending_subresource_continue_event( PendingSubresourceContinueEvent::Completed { internal_id }, ); - return true; + return timing; } - false + None } pub(crate) fn record_pending_subresource_continue_event( @@ -3298,3 +3315,19 @@ fn reject_fetch_continuation( let _ = resolver.reject(scope, reason); } } + +fn aborted_fetch_timing( + pending: &PendingSubresourceFetchState, +) -> Option<( + v8::Global, + crate::context_bootstrap::ResourcePerformanceEntry, +)> { + let fetch = pending.continuation.window_fetch()?; + if fetch.is_detached() { + return None; + } + Some(( + pending.execution_context.context_global()?.clone(), + fetch.timing.failure(&pending.info.url), + )) +} diff --git a/moli-renderer-v8/src/network_host/body_source.rs b/moli-renderer-v8/src/network_host/body_source.rs index 01d0f3bec9..e11a204388 100644 --- a/moli-renderer-v8/src/network_host/body_source.rs +++ b/moli-renderer-v8/src/network_host/body_source.rs @@ -2037,7 +2037,12 @@ fn pending_body_source_cancel_callback<'s>( &mut host.pending_network_body_clones, id, ) { - let _ = host.cancel_streaming_subresource_body_source(root_id); + let timing = host.cancel_streaming_subresource_body_source(root_id); + if let Some((context, entry)) = timing { + let context = v8::Local::new(scope, &context); + let scope = &mut v8::ContextScope::new(scope, context); + crate::context_bootstrap::record_resource_performance_entry(scope, entry); + } } } else if let Some(worker_state) = get_worker_state(scope) { let mut worker_state = worker_state.borrow_mut(); diff --git a/moli-renderer-v8/src/network_host/fetch/bindings.rs b/moli-renderer-v8/src/network_host/fetch/bindings.rs index 3c0f080d7d..1581b39c2b 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings.rs @@ -55,7 +55,14 @@ fn window_fetch_abort_signal_callback<'s>( .and_then(|signal| host.abort_signal_reason(scope, signal)) .unwrap_or_else(|| abort_error_value(scope)); let reason_payload = structured_serialize_fetch_abort_reason(scope, reason); - let _ = host.abort_fetch_promise(scope, internal_id, reason, reason_payload); + let timing = host.abort_fetch_promise(scope, internal_id, reason, reason_payload); + // The signal can belong to another realm. Report in the fetch's client + // realm, after releasing the mutable request-host borrow. + if let Some((context, entry)) = timing { + let context = v8::Local::new(scope, &context); + let scope = &mut v8::ContextScope::new(scope, context); + crate::context_bootstrap::record_resource_performance_entry(scope, entry); + } rv.set_undefined(); } diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs index a0023ecefe..0bac205a44 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch/fetch_dispatch.rs @@ -691,10 +691,7 @@ impl ScriptVm { match pending.continuation { PendingSubresourceContinuation::Fetch(fetch) => { let redirect_mode = fetch.redirect_mode(); - let resolver = fetch - .into_resolver() - .expect("detached keepalive completion is handled before V8 entry"); - let resolver = v8::Local::new(scope, &resolver); + let body_size = observable_response.body_bytes().len(); let (mut head, body) = observable_response.into_body(); let response_request = crate::network_host::FetchResponseRequest { method: &response_request_method, @@ -706,9 +703,20 @@ impl ScriptVm { &head, pending.credentials_mode, ))); + let entry = fetch.timing.response( + &pending.info.url, &pending.request_origin, &head, + response_filter.as_ref().expect("fetch response filter"), + body_size, + ); + crate::context_bootstrap::record_resource_performance_entry(scope, entry); if let Some(status_text) = response_status_text { head.status_text = Some(status_text); } + let resolver = fetch + .into_resolver() + .expect("detached keepalive completion is handled before V8 entry"); + let resolver = v8::Local::new(scope, &resolver); + let body = if opaque_response_blocked { moli_fetch::ResponseBody::materialized_bytes(Vec::new()) } else { @@ -902,6 +910,11 @@ impl ScriptVm { moli_trace::cdp_runtime_trace_enabled().then(Instant::now); match pending.continuation { PendingSubresourceContinuation::Fetch(fetch) => { + if !consumed_preload { + crate::context_bootstrap::record_resource_performance_entry( + scope, fetch.timing.failure(&pending.info.url), + ); + } let resolver = fetch .into_resolver() .expect("detached keepalive failure is handled before V8 entry"); diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs b/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs index b83221a218..f445af65e0 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs @@ -676,6 +676,9 @@ impl ScriptVm { ); return Ok(AsyncSubresourceFetchBodyActivity::NoWindowRealmEntered); }; + if let PendingSubresourceContinuation::Fetch(fetch) = &mut pending.continuation { + fetch.timing.response_started(); + } let trace_fields = async_subresource_trace_fields_for_pending_with_body( "streaming_started", started.internal_id, @@ -804,6 +807,11 @@ impl ScriptVm { .record_subresource_network(network_record); match &pending.continuation { PendingSubresourceContinuation::Fetch(fetch) => { + if !started.head.preload_state.is_consumed() { + crate::context_bootstrap::record_resource_performance_entry( + scope, fetch.timing.failure(&pending.info.url), + ); + } let resolver = fetch .resolver() .expect("detached keepalive stream is handled before V8 entry"); @@ -1755,6 +1763,13 @@ impl ScriptVm { &response_body, ) { + if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation + && !streaming.head.preload_state.is_consumed() + { + crate::context_bootstrap::record_resource_performance_entry( + scope, fetch.timing.failure(&streaming.pending.info.url), + ); + } let mut record = crate::types::SubresourceNetworkRecord::failure( streaming.pending.info.frame_id.clone(), streaming.pending.info.document_url.clone(), @@ -1779,6 +1794,24 @@ impl ScriptVm { return Ok(AsyncSubresourceFetchBodyActivity::WindowRealmEntered); } let response_body_size = response_body.len(); + if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation { + let response_request = crate::network_host::FetchResponseRequest { + method: &streaming.request_method, + mode: streaming.pending.request_mode, + redirect_mode: fetch.redirect_mode(), + }; + let filter = streaming.response_filter.clone().unwrap_or_else(|| { + response_request.network_response_filter( + &streaming.pending.request_origin, &streaming.head, + streaming.pending.credentials_mode, + ) + }); + let entry = fetch.timing.response( + &streaming.pending.info.url, &streaming.pending.request_origin, + &streaming.head, &filter, response_body_size, + ); + crate::context_bootstrap::record_resource_performance_entry(scope, entry); + } trace_async_subresource_stage( "async_subresource_streaming_body_finished", trace_fields, @@ -1978,6 +2011,13 @@ impl ScriptVm { } } Err(error_text) => { + if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation + && !streaming.head.preload_state.is_consumed() + { + crate::context_bootstrap::record_resource_performance_entry( + scope, fetch.timing.failure(&streaming.pending.info.url), + ); + } let error_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); crate::network_host::error_pending_network_body_stream( diff --git a/moli-renderer-v8/src/script_vm/tests/fetch_resource_timing.rs b/moli-renderer-v8/src/script_vm/tests/fetch_resource_timing.rs new file mode 100644 index 0000000000..504b4f5bd5 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/fetch_resource_timing.rs @@ -0,0 +1,164 @@ +use super::*; +use parking_lot::Mutex; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Default)] +struct TimingServerState { + seen: HashSet, + gates: HashMap>, +} + +struct TimingServer { + origin: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for TimingServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn timing_server() -> TimingServer { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let state = Arc::new(Mutex::new(TimingServerState::default())); + let task = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + loop { + let (socket, _) = listener.accept().await.unwrap(); + let state = state.clone(); + connections.spawn(handle_timing_request(socket, state)); + while let Some(result) = connections.try_join_next() { + result.unwrap(); + } + } + }); + TimingServer { origin, task } +} + +async fn handle_timing_request( + mut socket: tokio::net::TcpStream, + state: Arc>, +) { + let mut request = Vec::new(); + while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let mut buffer = [0; 2048]; + let count = socket.read(&mut buffer).await.unwrap(); + if count == 0 { + return; + } + request.extend_from_slice(&buffer[..count]); + } + let request = String::from_utf8(request).unwrap(); + let target = request.split_whitespace().nth(1).unwrap(); + let url = url::Url::parse(&format!("http://fixture.test{target}")).unwrap(); + let query: HashMap<_, _> = url.query_pairs().into_owned().collect(); + let get = |name: &str| query.get(name).map(String::as_str).unwrap_or(""); + let key = get("key").to_owned(); + let gate = { + let mut state = state.lock(); + state.seen.insert(key.clone()); + state.gates.entry(key).or_default().clone() + }; + if url.path() == "/fail" { + return; + } + use base64::Engine; + const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jL1kAAAAASUVORK5CYII="; + let mut body = base64::engine::general_purpose::STANDARD + .decode(PNG) + .unwrap(); + let mut mime = "image/png"; + if url.path() == "/release" { + gate.notify_one(); + body = b"ok".to_vec(); + mime = "text/plain"; + } else if url.path() == "/seen" { + body = state + .lock() + .seen + .contains(get("target")) + .to_string() + .into_bytes(); + mime = "application/json"; + } + if get("gate") == "before" { + gate.notified().await; + } + let redirect = url.path() == "/redirect"; + if redirect { + body.clear(); + } + let status = get("status") + .parse::() + .unwrap_or(if redirect { 302 } else { 200 }); + if status == 204 { + body.clear(); + } + let mut headers = format!( + "HTTP/1.1 {status} Fixture\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n", + body.len() + ); + if redirect { + headers.push_str(&format!("Location: {}\r\n", get("to"))); + } + for (query_name, header_name) in [ + ("cors", "Access-Control-Allow-Origin"), + ("tao", "Timing-Allow-Origin"), + ("tao2", "Timing-Allow-Origin"), + ] { + if !get(query_name).is_empty() { + headers.push_str(&format!("{header_name}: {}\r\n", get(query_name))); + } + } + headers.push_str("\r\n"); + if socket.write_all(headers.as_bytes()).await.is_err() { + return; + } + if get("truncate") == "1" { + body.truncate(4); + } + if get("gate") == "body" { + if socket.write_all(&body[..4]).await.is_err() { + return; + } + gate.notified().await; + body.drain(..4); + } + let _ = socket.write_all(&body).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn window_fetch_reports_native_resource_timing_at_body_terminal() { + let first = timing_server().await; + let second = timing_server().await; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut page = + new_storage_page_task_executor_test_vm_with_loader(&format!("{}/", first.origin), &loader); + page.eval(&format!( + "globalThis.__fetchOrigins = {}; globalThis.__fetchTimingResult = null;", + serde_json::to_string(&[&first.origin, &second.origin]).unwrap(), + )) + .unwrap(); + let fixture = include_str!("../../../tests/fixtures/fetch-resource-timing.js"); + page.eval(&format!( + "({}).then(value => __fetchTimingResult = value, error => __fetchTimingResult = String(error));", + fixture.trim().trim_end_matches(';'), + )).unwrap(); + advance_page_task_executor_until_eval_equals( + &mut page, + &loader, + "String(__fetchTimingResult !== null)", + "true", + "fetch Resource Timing", + ) + .await; + let result = page.eval("__fetchTimingResult").unwrap(); + let result: serde_json::Value = serde_json::from_str(&result) + .unwrap_or_else(|error| panic!("fetch timing probe returned {result:?}: {error}")); + assert_eq!(result["total"], 36); + assert_eq!(result["failures"], serde_json::json!([])); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index b1b1437e80..2e9e475cd3 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2240,6 +2240,7 @@ mod event_receivers; mod fetch_integrity; mod fetch_referrer; mod fetch_request_guard; +mod fetch_resource_timing; mod frame_element_security; mod geometry_point_conversion_order; mod headers_list; diff --git a/moli-renderer-v8/src/types.rs b/moli-renderer-v8/src/types.rs index 11cedd46af..36a1df00c0 100644 --- a/moli-renderer-v8/src/types.rs +++ b/moli-renderer-v8/src/types.rs @@ -264,6 +264,7 @@ impl PendingSubresourceContinuation { } pub(super) struct PendingWindowFetchContinuation { + pub(super) timing: crate::context_bootstrap::FetchResourceTiming, promise: PendingWindowFetchPromise, keepalive: bool, connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, @@ -289,6 +290,7 @@ impl PendingWindowFetchContinuation { ) -> Self { Self { promise: PendingWindowFetchPromise::Active(resolver), + timing: crate::context_bootstrap::FetchResourceTiming::new(), keepalive, redirect_csp_state: crate::network_host::FetchCspRedirectState::new(&connect_policy), connect_policy, diff --git a/moli-renderer-v8/tests/fixtures/fetch-resource-timing.js b/moli-renderer-v8/tests/fixtures/fetch-resource-timing.js new file mode 100644 index 0000000000..de2bd2445f --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/fetch-resource-timing.js @@ -0,0 +1,208 @@ +(async () => { + const rows = [], bodySize = 68; + let sequence = 0; + const assert = (condition, message) => { if (!condition) throw new Error(message); }; + const test = async (name, run) => { + try { await run(); rows.push({name, passed: true}); } + catch (error) { rows.push({name, passed: false, error: String(error)}); } + }; + const turn = () => new Promise(resolve => setTimeout(resolve, 0)); + const urlFor = (origin = 0, query = '') => `${__fetchOrigins[origin]}/resource?key=case-${++sequence}&${query}`; + const entryFor = (realm, url) => new Promise((resolve, reject) => { + const previous = realm.performance.getEntriesByName(url, 'resource'); + if (previous.length) { resolve(previous[previous.length - 1]); return; } + const observer = new realm.PerformanceObserver(list => { + const entry = list.getEntriesByName(url, 'resource')[0]; + if (entry) { clearTimeout(timer); observer.disconnect(); resolve(entry); } + }); + observer.observe({type: 'resource'}); + const timer = setTimeout(() => { observer.disconnect(); reject(new Error('resource entry was not reported')); }, 500); + }); + const checkEntry = (realm, entry, url) => { + assert(entry instanceof realm.PerformanceResourceTiming, 'native resource interface'); + assert(entry.name === url && entry.entryType === 'resource' && entry.initiatorType === 'fetch', 'entry identity'); + assert(entry.startTime >= 0 && entry.responseEnd >= entry.startTime && entry.duration >= 0, 'start and body-terminal timestamps'); + assert(Math.abs(entry.duration - (entry.responseEnd - entry.startTime)) < 0.00001, 'duration uses body terminal'); + const json = entry.toJSON(); + for (const key of ['name', 'startTime', 'responseEnd', 'duration', 'encodedBodySize', 'decodedBodySize', 'responseStatus']) + assert(json[key] === entry[key], `native JSON ${key}`); + assert(realm.performance.getEntriesByName(url, 'resource').length === 1, 'exactly one entry per fetch'); + }; + for (const status of [200, 204, 404, 503]) await test(`same-origin status ${status}`, async () => { + const url = urlFor(0, `status=${status}`), response = await fetch(url), bytes = await response.arrayBuffer(); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.responseStatus === status, 'HTTP status is not a network error'); + assert(entry.encodedBodySize === bytes.byteLength && entry.decodedBodySize === bytes.byteLength, 'completed response body size'); + assert(entry.transferSize === bytes.byteLength + 300, 'uncached transfer overhead'); + }); + for (const tao of [false, true]) for (const mode of ['cors', 'no-cors']) await test(`${mode} cross-origin TAO=${tao}`, async () => { + const url = urlFor(1, `${mode === 'cors' ? 'cors=*&' : ''}${tao ? 'tao=*' : ''}`); + const response = await fetch(url, {mode}); await response.arrayBuffer(); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + const readable = mode === 'cors'; + assert(entry.responseStatus === (readable ? 200 : 0), 'filtered response status'); + assert(entry.encodedBodySize === (readable ? bodySize : 0) && entry.decodedBodySize === (readable ? bodySize : 0), 'body sizes follow CORS, independently of TAO'); + assert(entry.transferSize === (tao ? entry.encodedBodySize + 300 : 0), 'transfer size follows TAO'); + if (!tao) assert(entry.responseStart === 0 && entry.nextHopProtocol === '', 'TAO hides connection details'); + }); + for (const failure of ['network', 'cors']) await test(`${failure} failure still completes timing`, async () => { + const url = failure === 'network' ? __fetchOrigins[0] + '/fail?key=network-failure' : urlFor(1); + let error; try { await fetch(url); } catch (caught) { error = caught; } + assert(error instanceof TypeError, 'fetch rejects'); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + for (const key of ['responseStatus', 'transferSize', 'encodedBodySize', 'decodedBodySize', 'responseStart']) assert(entry[key] === 0, `failed ${key} is opaque`); + }); + await test('streaming timing waits for EOF and ignores Response clones', async () => { + const key = `stream-${++sequence}`, url = `${__fetchOrigins[0]}/resource?gate=body&key=${key}`; + const response = await fetch(url), copy = response.clone(); + await turn(); assert(performance.getEntriesByName(url).length === 0, 'headers are not completion'); + const first = response.arrayBuffer(), second = copy.arrayBuffer(); + await fetch(`${__fetchOrigins[0]}/release?key=${key}`); + assert((await first).byteLength === bodySize && (await second).byteLength === bodySize, 'both branches receive the body'); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + await turn(); assert(performance.getEntriesByName(url).length === 1, 'cloning does not duplicate completion'); + }); + for (const gate of ['before', 'body']) await test(`AbortSignal during ${gate}`, async () => { + const key = `abort-${gate}-${++sequence}`, url = `${__fetchOrigins[0]}/resource?gate=${gate}&key=${key}`; + const controller = new AbortController(); let reason; + try { + if (gate === 'body') { + const response = await fetch(url, {signal: controller.signal}), body = response.arrayBuffer(); + controller.abort(); try { await body; } catch (error) { reason = error; } + } else { + const response = fetch(url, {signal: controller.signal}); + while (!await fetch(`${__fetchOrigins[0]}/seen?target=${key}`).then(r => r.json())) await turn(); + controller.abort(); try { await response; } catch (error) { reason = error; } + } + assert(reason?.name === 'AbortError', 'abort reason is preserved'); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.responseStatus === 0 && entry.encodedBodySize === 0 && entry.decodedBodySize === 0, 'aborted response exposes no partial body metadata'); + } finally { await fetch(`${__fetchOrigins[0]}/release?key=${key}`); } + await turn(); assert(performance.getEntriesByName(url).length === 1, 'late terminal after abort is ignored'); + }); + await test('pre-aborted fetch has no timing entry', async () => { + const url = urlFor(), controller = new AbortController(); controller.abort(); + try { await fetch(url, {signal: controller.signal}); } catch (error) { assert(error.name === 'AbortError', 'pre-abort reason'); } + await turn(); assert(performance.getEntriesByName(url).length === 0, 'no fetch was started'); + }); + await test('child fetch reports in its client realm', async () => { + const frame = document.body.appendChild(document.createElement('iframe')), realm = frame.contentWindow; + try { + const url = urlFor(), response = await realm.fetch(url); await response.arrayBuffer(); + const entry = await entryFor(realm, url); checkEntry(realm, entry, url); + assert(!(entry instanceof PerformanceResourceTiming) && performance.getEntriesByName(url).length === 0, 'parent timeline is separate'); + } finally { frame.remove(); } + }); + await test('resource observer receives entries with a zero buffer size', async () => { + const url = urlFor(); performance.clearResourceTimings(); performance.setResourceTimingBufferSize(0); + const observed = entryFor(window, url); + try { + const response = await fetch(url); await response.arrayBuffer(); + const entry = await observed; + assert(entry.name === url && entry.initiatorType === 'fetch', 'observer delivery'); + assert(performance.getEntriesByName(url).length === 0, 'timeline stays empty'); + } finally { performance.setResourceTimingBufferSize(250); } + }); + const redirectTo = (origin, target, tao = '') => `${__fetchOrigins[origin]}/redirect?key=redirect-${++sequence}&to=${encodeURIComponent(target)}&cors=*&tao=${encodeURIComponent(tao)}`; + await test('redirect records the initial URL once', async () => { + const target = urlFor(), url = redirectTo(0, target); + const response = await fetch(url); await response.arrayBuffer(); + checkEntry(window, await entryFor(window, url), url); + assert(performance.getEntriesByName(target).length === 0, 'no separate redirect-hop entry'); + }); + for (const [middleTAO, finalTAO, allowed] of [ + ['', '*', false], ['*', '*', true], + [__fetchOrigins[0], 'null', true], [__fetchOrigins[0], __fetchOrigins[0], false] + ]) await test(`cross-origin redirect TAO ${middleTAO}/${finalTAO}`, async () => { + const target = urlFor(0, `cors=*&tao=${encodeURIComponent(finalTAO)}`); + const url = redirectTo(0, redirectTo(1, target, middleTAO)); + const response = await fetch(url); await response.arrayBuffer(); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.responseStatus === 200 && entry.encodedBodySize === bodySize, 'CORS-visible redirected response'); + assert(entry.transferSize === (allowed ? bodySize + 300 : 0), 'TAO is sticky and uses the serialized request origin'); + if (!allowed) assert(entry.responseStart === 0 && entry.nextHopProtocol === '', 'redirect TAO failure hides timing'); + }); + await test('manual redirect keeps filtered metadata', async () => { + const url = redirectTo(0, urlFor()); + const response = await fetch(url, {redirect: 'manual'}); + assert(response.type === 'opaqueredirect', 'filtered redirect'); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.responseStatus === 0 && entry.encodedBodySize === 0 && entry.decodedBodySize === 0, 'opaque redirect metadata'); + }); + await test('redirect error completes timing', async () => { + const url = redirectTo(0, urlFor()); + let reason; try { await fetch(url, {redirect: 'error'}); } catch (error) { reason = error; } + assert(reason instanceof TypeError, 'redirect mode rejects'); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.responseStatus === 0 && entry.transferSize === 0, 'failed redirect metadata'); + }); + await test('body cancellation reports one terminal entry', async () => { + const key = `cancel-${++sequence}`, url = `${__fetchOrigins[0]}/resource?gate=body&key=${key}`; + try { + const response = await fetch(url); await response.body.cancel(); + checkEntry(window, await entryFor(window, url), url); + } finally { await fetch(`${__fetchOrigins[0]}/release?key=${key}`); } + await turn(); assert(performance.getEntriesByName(url).length === 1, 'late EOF does not report twice'); + }); + await test('unconsumed response completes timing', async () => { + const url = urlFor(), response = await fetch(url); + checkEntry(window, await entryFor(window, url), url); + assert((await response.arrayBuffer()).byteLength === bodySize, 'reading later preserves the body'); + await turn(); assert(performance.getEntriesByName(url).length === 1, 'reading later does not report twice'); + }); + await test('parent AbortSignal reports only in child fetch realm', async () => { + const frame = document.body.appendChild(document.createElement('iframe')), realm = frame.contentWindow; + const key = `child-abort-${++sequence}`, url = `${__fetchOrigins[0]}/resource?gate=body&key=${key}`; + try { + const controller = new AbortController(), response = await realm.fetch(url, {signal: controller.signal}); + const body = response.arrayBuffer(); controller.abort(); + try { await body; } catch (error) { assert(error.name === 'AbortError', 'child body abort'); } + checkEntry(realm, await entryFor(realm, url), url); + assert(performance.getEntriesByName(url).length === 0, 'parent timeline remains separate'); + } finally { await fetch(`${__fetchOrigins[0]}/release?key=${key}`); frame.remove(); } + }); + for (const [tao, tao2, allowed] of [ + ['"*"', '', false], ['"ignored,*,value"', '', false], + [`https://unused.test, ${__fetchOrigins[0]}`, '', true], + ['"ignored,', '*,value"', false], ['https://unused.test', '*', true] + ]) await test(`TAO list ${tao}/${tao2}`, async () => { + const url = urlFor(1, `cors=*&tao=${encodeURIComponent(tao)}&tao2=${encodeURIComponent(tao2)}`); + await fetch(url).then(response => response.arrayBuffer()); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.transferSize === (allowed ? bodySize + 300 : 0), 'quoted tokens and repeated fields follow Fetch list parsing'); + }); + for (const valid of [true, false]) await test(`integrity validation ${valid}`, async () => { + const url = urlFor(), integrity = 'sha256-' + (valid ? '0vzSmlPFahjQJNdFMsA6oL5kgbIzB+klmeVz0zlid/M=' : 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='); + let response, reason; try { response = await fetch(url, {integrity}); } catch (error) { reason = error; } + if (valid) assert((await response.arrayBuffer()).byteLength === bodySize, 'valid integrity body'); + else assert(reason instanceof TypeError, 'integrity mismatch rejects'); + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.responseStatus === (valid ? 200 : 0) && entry.encodedBodySize === (valid ? bodySize : 0), 'integrity response metadata'); + }); + await test('data and blob fetches do not create HTTP timing entries', async () => { + const blob = URL.createObjectURL(new Blob(['local'])); + try { + for (const url of ['data:text/plain,local', blob]) { + assert(await fetch(url).then(response => response.text()) === 'local', 'local fetch succeeds'); + await turn(); assert(performance.getEntriesByName(url).length === 0, 'non-HTTP fetch excluded'); + } + } finally { URL.revokeObjectURL(blob); } + }); + await test('canceling one clone leaves the fetch running', async () => { + const key = `clone-cancel-${++sequence}`, url = `${__fetchOrigins[0]}/resource?gate=body&key=${key}`; + const response = await fetch(url), copy = response.clone(), canceled = response.body.cancel(); + assert(performance.getEntriesByName(url).length === 0, 'one branch is not fetch completion'); + const body = copy.arrayBuffer(); await fetch(`${__fetchOrigins[0]}/release?key=${key}`); + assert((await body).byteLength === bodySize, 'other branch retains the complete response'); + await canceled; + const entry = await entryFor(window, url); checkEntry(window, entry, url); + assert(entry.responseStatus === 200 && entry.encodedBodySize === bodySize, 'successful terminal remains successful'); + }); + await test('truncated response reports a terminal entry', async () => { + const url = urlFor(0, 'truncate=1'); + let reason; try { await fetch(url).then(response => response.arrayBuffer()); } catch (error) { reason = error; } + assert(reason instanceof TypeError, 'truncated response rejects'); + checkEntry(window, await entryFor(window, url), url); + }); + return JSON.stringify({total: rows.length, failures: rows.filter(row => !row.passed)}); +})();