From 4b8101f3356cc88f45eb903179ed1ff8a3e6efa5 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 27 Sep 2026 02:27:47 +0800 Subject: [PATCH] fix(performance): report Window fetch resource timing Report registered Window fetches at body completion, failure, and cancellation in the request client realm. Keep original URLs, native entry state, preload suppression, and one terminal entry across clones and late completions. Separate CORS-visible body metadata from TAO-protected timing and transfer size, including redirect taint and quoted/repeated TAO header fields. Validate 36 real HTTP scenarios and isolate preload fixture timing buffers. Workspace fmt, all-target/all-feature Clippy, and 20,255 Nextest tests pass. --- .../fixtures/preload-consumption-style.js | 2 + .../tests/fixtures/preload-consumption.js | 3 + moli-renderer-v8/src/context_bootstrap.rs | 5 +- .../context_bootstrap/performance_runtime.rs | 66 +++++- .../performance_runtime/fetch_timing.rs | 121 ++++++++++ .../window_state/pending.rs | 23 +- .../context_host/resource_loading.rs | 61 +++-- .../src/network_host/body_source.rs | 7 +- .../src/network_host/fetch/bindings.rs | 9 +- .../subresource_fetch/fetch_dispatch.rs | 21 +- .../script_vm/subresource_fetch/streaming.rs | 40 ++++ .../script_vm/tests/fetch_resource_timing.rs | 164 ++++++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + moli-renderer-v8/src/types.rs | 2 + .../tests/fixtures/fetch-resource-timing.js | 208 ++++++++++++++++++ 15 files changed, 706 insertions(+), 27 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/fetch_resource_timing.rs create mode 100644 moli-renderer-v8/tests/fixtures/fetch-resource-timing.js 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)}); +})();