diff --git a/moli-core/tests/fetch_behaviors/cache_timing.rs b/moli-core/tests/fetch_behaviors/cache_timing.rs index c8e05f14a4..15a689d240 100644 --- a/moli-core/tests/fetch_behaviors/cache_timing.rs +++ b/moli-core/tests/fetch_behaviors/cache_timing.rs @@ -96,21 +96,28 @@ async fn resource_timing_distinguishes_network_local_and_revalidated_responses() let browser = Browser::new(config)?; let mut page = browser.fetch(&format!("http://{addr}/")).await?; let fixture = include_str!("../fixtures/cache-resource-timing.js"); - for context in ["top", "child"] { + for (context, transport) in [ + ("top", "xhr"), + ("child", "xhr"), + ("top", "fetch"), + ("child", "fetch"), + ] { + let fixture = format!( + "(() => {{globalThis.cacheTimingContext = '{context}-{transport}';globalThis.cacheTimingTransport = '{transport}';return {fixture};}})()" + ); let script = if context == "top" { - fixture.to_owned() + fixture.clone() } else { format!( r#"(async () => {{ const frame = document.createElement('iframe'); frame.srcdoc = 'child'; await new Promise(resolve => {{ frame.onload=resolve; document.body.append(frame); }}); - frame.contentWindow.cacheTimingContext = 'child'; const result = await frame.contentWindow.eval({}); frame.remove(); return result; }})()"#, - serde_json::to_string(fixture)? + serde_json::to_string(&fixture)? ) }; let result = tokio::time::timeout( @@ -155,7 +162,7 @@ async fn resource_timing_distinguishes_network_local_and_revalidated_responses() {"padding":0,"transfer":306,"encoded":6}, {"padding":4096,"transfer":306,"encoded":6} ]}), - "{context}" + "{context}, {transport}" ); } let _ = shutdown.send(()); diff --git a/moli-core/tests/fixtures/cache-resource-timing.js b/moli-core/tests/fixtures/cache-resource-timing.js index ea8de41573..c7215bb98b 100644 --- a/moli-core/tests/fixtures/cache-resource-timing.js +++ b/moli-core/tests/fixtures/cache-resource-timing.js @@ -7,6 +7,8 @@ request.send(); }); const context = globalThis.cacheTimingContext || 'top'; + const request = globalThis.cacheTimingTransport === 'fetch' + ? url => fetch(url).then(response => response.text()) : xhr; const resourceUrl = (path, parameters) => { const url = new URL(path, document.baseURI); url.search = new URLSearchParams({...parameters, context}); @@ -17,7 +19,7 @@ const url = resourceUrl('/probe-cache', {mode}); const observations = []; for (let i = 0; i < 3; i++) { - const body = await xhr(url); + const body = await request(url); await new Promise(resolve => setTimeout(resolve, 0)); const entries = performance.getEntriesByName(url); const entry = entries[entries.length - 1]; @@ -36,7 +38,7 @@ const headers = []; for (const padding of [0, 4096]) { const url = resourceUrl('/probe-cache', {mode: 'padding', padding}); - await xhr(url); + await request(url); await new Promise(resolve => setTimeout(resolve, 0)); const entry = performance.getEntriesByName(url)[0]; headers.push({padding, transfer: entry.transferSize, encoded: entry.encodedBodySize}); diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index a87e1ffb24..b12b21d9d4 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -447,10 +447,10 @@ pub(crate) use self::notification_runtime::{ notification_options_payload, }; pub(crate) use self::performance_runtime::{ - ResourcePerformanceEntry, bind_document_performance, bind_window_performance_seed, - current_performance_time_origin, increment_performance_event_count, - project_document_lifecycle_timing, record_resource_performance_entry, - run_resource_timing_buffer_full_task, + FetchResourceTiming, ResourcePerformanceEntry, bind_document_performance, + bind_window_performance_seed, current_performance_time_origin, + increment_performance_event_count, project_document_lifecycle_timing, + record_resource_performance_entry, run_resource_timing_buffer_full_task, }; use self::range_live::{ update_live_ranges_for_character_data_edit, update_live_ranges_for_character_data_reset, diff --git a/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs b/moli-renderer-v8/src/context_bootstrap/performance_runtime.rs index 1148a44cd4..09266f7140 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; 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..b43b27c15b --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/performance_runtime/fetch_timing.rs @@ -0,0 +1,91 @@ +use super::ResourcePerformanceEntry; +use crate::types::AsyncSubresourceFetchResponseFilter; +use moli_fetch::{FetchUrlList, RequestMode, ResponseCacheState, ResponseHead}; +use moli_url::WebOrigin; +use url::Url; + +/// Request-time metadata, independent of the callback or Realm that happens to +/// be active when the body finishes. This must not retain any V8 objects. +#[derive(Clone)] +pub(crate) struct FetchResourceTiming { + start_unix_millis: f64, + initiator_url: String, +} + +impl FetchResourceTiming { + pub(crate) fn capture(scope: &mut v8::PinScope<'_, '_>, origin: &WebOrigin) -> Self { + Self { + start_unix_millis: crate::context_bootstrap::unix_epoch_millis(), + initiator_url: crate::resource_initiator::current_url(scope, origin), + } + } + + pub(crate) fn completed( + &self, + url: &Url, + origin: &WebOrigin, + mode: RequestMode, + head: &ResponseHead, + body_size: usize, + filter: Option, + ) -> ResourcePerformanceEntry { + let filter = + filter.or_else(|| crate::network_host::network_response_filter(origin, head, mode)); + let readable = filter.is_none_or(|filter| filter.is_readable()); + let mut entry = ResourcePerformanceEntry::from_streaming_network_response( + url.as_str(), + "fetch", + Some(self.start_unix_millis), + head, + body_size, + ); + // CORS controls response metadata and script attribution. TAO controls + // timing/body sizes separately, including for opaque responses. + if readable && head.cache_state == ResponseCacheState::None { + entry.initiator_url.clone_from(&self.initiator_url); + } + if !readable { + entry.response_status = 0.0; + entry.content_type.clear(); + } + if !timing_allowed(origin, head) { + entry.transfer_size = 0.0; + entry.encoded_body_size = 0.0; + entry.decoded_body_size = 0.0; + } + entry + } + + pub(crate) fn failed(&self, url: &Url) -> ResourcePerformanceEntry { + ResourcePerformanceEntry::from_network_failure( + url.as_str(), + "fetch", + Some(self.start_unix_millis), + ) + } +} + +fn timing_allowed(origin: &WebOrigin, head: &ResponseHead) -> bool { + // Once a redirect has failed TAO, a later response cannot restore access. + // Likewise, returning home does not reset cross-origin response tainting. + let mut cross_origin = false; + for (index, redirect) in head.redirect_chain.iter().enumerate() { + cross_origin |= !origin.same_origin_url(&redirect.from_url); + let urls = FetchUrlList::new(&redirect.from_url, &head.redirect_chain[..index]); + if cross_origin && !allows_timing(&redirect.headers, &urls.serialized_origin(origin)) { + return false; + } + } + cross_origin |= !origin.same_origin_url(&head.final_url); + !cross_origin || allows_timing(&head.headers, &head.url_list().serialized_origin(origin)) +} + +fn allows_timing(headers: &[(String, Vec)], origin: &str) -> bool { + headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("timing-allow-origin")) + .filter_map(|(_, value)| std::str::from_utf8(value).ok()) + .flat_map(|value| value.split(',')) + .map(|value| value.trim_matches(|ch: char| ch.is_ascii_whitespace())) + .any(|value| value == "*" || value == origin) +} 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 001d9c6c4f..76a661a94d 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 @@ -601,6 +601,7 @@ impl JsContextHost { &mut self, fetch_context: super::WindowFetchContext, resolver: v8::Global, + resource_timing: crate::context_bootstrap::FetchResourceTiming, keepalive: bool, connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, csp_report_context: crate::network_host::WindowCspReportRequestContext, @@ -641,6 +642,7 @@ impl JsContextHost { continuation: PendingSubresourceContinuation::Fetch( crate::types::PendingWindowFetchContinuation::new( resolver, + resource_timing, keepalive, cache_mode, connect_policy, @@ -886,6 +888,7 @@ impl JsContextHost { &mut self, fetch_context: super::WindowFetchContext, resolver: v8::Global, + resource_timing: crate::context_bootstrap::FetchResourceTiming, keepalive: bool, connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, csp_report_context: crate::network_host::WindowCspReportRequestContext, @@ -931,6 +934,7 @@ impl JsContextHost { continuation: PendingSubresourceContinuation::Fetch( crate::types::PendingWindowFetchContinuation::new( resolver, + resource_timing, keepalive, cache_mode, connect_policy, diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs index b8798341b4..7d28348b11 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/paths.rs @@ -23,6 +23,7 @@ pub(super) fn record_intercepted_fetch( host.record_pending_subresource_fetch( prepared.fetch_context, v8::Global::new(scope, resolver), + prepared.resource_timing, prepared.keepalive, prepared.connect_policy, prepared.csp_report_context, @@ -243,6 +244,7 @@ pub(super) fn spawn_network_fetch( let internal_id = host.record_async_subresource_fetch( prepared.fetch_context, v8::Global::new(scope, resolver), + prepared.resource_timing, prepared.keepalive, prepared.connect_policy, prepared.csp_report_context, diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs index 233bfa852f..ca826cf071 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/request.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/request.rs @@ -59,6 +59,7 @@ pub(super) struct PreparedWindowFetchRequest { pub(super) csp_report_context: crate::network_host::WindowCspReportRequestContext, pub(super) document_url: url::Url, pub(super) request_origin: moli_url::WebOrigin, + pub(super) resource_timing: crate::context_bootstrap::FetchResourceTiming, pub(super) network_partition_key: Option, pub(super) document_referrer_policy: Option, pub(super) policy_context: crate::types::SubresourcePolicyContext, @@ -130,6 +131,10 @@ pub(super) fn prepare_window_fetch_request<'s>( let resolved_url = resolve_context_url(&base_url, &parsed.url, None)?; Ok(PreparedWindowFetchRequest { + resource_timing: crate::context_bootstrap::FetchResourceTiming::capture( + scope, + &request_origin, + ), frame_id, fetch_context, resource_loader, diff --git a/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs b/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs index 9008b99c8d..1f6bc6232a 100644 --- a/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs +++ b/moli-renderer-v8/src/network_host/fetch/bindings/service_worker.rs @@ -52,6 +52,7 @@ pub(super) fn dispatch_service_worker_fetch( let internal_id = host.record_async_subresource_fetch( prepared.fetch_context.duplicate(scope), v8::Global::new(scope, resolver), + prepared.resource_timing.clone(), prepared.keepalive, prepared.connect_policy.clone(), prepared.csp_report_context.clone(), 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 338ca8317c..bd6112c2fd 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 @@ -657,6 +657,21 @@ impl ScriptVm { trace_fields, record_started, ); + if let PendingSubresourceContinuation::Fetch(fetch) = &pending.continuation { + crate::context_bootstrap::record_resource_performance_entry( + scope, + fetch.resource_timing().completed( + &pending.info.url, + &pending.request_origin, + pending.request_mode, + &response.head(), + response.body_bytes().len(), + opaque_response_blocked + .then_some(AsyncSubresourceFetchResponseFilter::Opaque) + .or(response_filter), + ), + ); + } let mut observable_response = response; if matches!( pending.info.resource_type, @@ -878,6 +893,10 @@ impl ScriptVm { moli_trace::cdp_runtime_trace_enabled().then(Instant::now); match pending.continuation { PendingSubresourceContinuation::Fetch(fetch) => { + crate::context_bootstrap::record_resource_performance_entry( + scope, + fetch.resource_timing().failed(&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 a7d521c813..f63ca5b247 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch/streaming.rs @@ -727,6 +727,10 @@ impl ScriptVm { .record_subresource_network(network_record); match &pending.continuation { PendingSubresourceContinuation::Fetch(fetch) => { + crate::context_bootstrap::record_resource_performance_entry( + scope, + fetch.resource_timing().failed(&pending.info.url), + ); let resolver = fetch .resolver() .expect("detached keepalive stream is handled before V8 entry"); @@ -1799,6 +1803,19 @@ impl ScriptVm { trace_fields, record_started, ); + if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation { + crate::context_bootstrap::record_resource_performance_entry( + scope, + fetch.resource_timing().completed( + &streaming.pending.info.url, + &streaming.pending.request_origin, + streaming.pending.request_mode, + &streaming.head, + response_body_size, + streaming.response_filter, + ), + ); + } if let PendingSubresourceContinuation::Xhr { xhr, initiator_url, .. } = streaming.pending.continuation && let Some(response_body) = xhr_delivery_body @@ -1851,6 +1868,12 @@ impl ScriptVm { } } Err(error_text) => { + if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation { + crate::context_bootstrap::record_resource_performance_entry( + scope, + fetch.resource_timing().failed(&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/dom_xhr/xhr.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs index 1a1fd43f54..7a08b7abef 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/xhr.rs @@ -157,6 +157,10 @@ fn pending_fetch_continuation<'s>( crate::types::PendingSubresourceContinuation::Fetch( crate::types::PendingWindowFetchContinuation::new( v8::Global::new(scope, resolver), + crate::context_bootstrap::FetchResourceTiming::capture( + scope, + &host.document_url().into(), + ), false, moli_fetch::RequestCacheMode::Default, crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container( diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index fba936f2fd..b63c0fdcf4 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -327,6 +327,7 @@ fn register_pending_window_fetch_for_test( let internal_id = host.record_async_subresource_fetch( fetch_context, v8::Global::new(scope, resolver), + crate::context_bootstrap::FetchResourceTiming::capture(scope, &(&url).into()), keepalive, connect_policy, csp_report_context, @@ -500,6 +501,7 @@ fn register_pending_window_fetch_with_connect_policy_for_test( let internal_id = host.record_async_subresource_fetch( fetch_context, v8::Global::new(scope, resolver), + crate::context_bootstrap::FetchResourceTiming::capture(scope, &(&document_url).into()), keepalive, crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(&policy), csp_report_context, diff --git a/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs b/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs index 15327c9e9b..141ec61c5b 100644 --- a/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs +++ b/moli-renderer-v8/src/script_vm/tests/resource_initiator.rs @@ -2,6 +2,7 @@ use super::*; use crate::script_provenance::CompiledStringProvenance; mod event_handler_getter; +mod fetch; mod geolocation_watch; mod realm_lifetime; diff --git a/moli-renderer-v8/src/script_vm/tests/resource_initiator/fetch.rs b/moli-renderer-v8/src/script_vm/tests/resource_initiator/fetch.rs new file mode 100644 index 0000000000..dc255eebe7 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/resource_initiator/fetch.rs @@ -0,0 +1,345 @@ +use super::*; + +fn finish_fetch( + vm: &mut ScriptVm, + request: &crate::types::PendingSubresourceFetchInfo, + response: crate::protocol_types::NavigationResponse, + streaming: bool, +) { + if streaming { + let body_source_id = crate::network_host::new_network_body_source_id(); + vm.start_streaming_async_subresource_fetch( + crate::types::AsyncSubresourceStreamingStarted { + internal_id: request.internal_id, + request_url: request.url.clone(), + request_method: request.method.clone(), + request_headers: request.request_headers.clone(), + request_body: None, + skip_fetch_security_validation: false, + response_filter: None, + body_source_id, + head: response.head(), + network_request_headers: None, + }, + ) + .unwrap(); + assert_eq!( + vm.eval("String(performance.getEntriesByType('resource').length)") + .unwrap(), + "0", + "headers must not publish an unfinished Fetch" + ); + vm.append_streaming_async_subresource_fetch_chunk( + body_source_id, + response.body_bytes().to_vec(), + ); + assert_eq!( + vm.eval("String(performance.getEntriesByType('resource').length)") + .unwrap(), + "0", + "body chunks must not publish an unfinished Fetch" + ); + vm.finish_streaming_async_subresource_fetch(request.internal_id, body_source_id, Ok(())) + .unwrap(); + } else { + vm.complete_async_subresource_fetch(crate::types::AsyncSubresourceFetchCompletion { + internal_id: request.internal_id, + request_url: request.url.clone(), + request_method: request.method.clone(), + request_headers: request.request_headers.clone(), + request_body: None, + response_status_text: None, + skip_fetch_security_validation: false, + response_filter: None, + network_error_text: None, + result: Ok(response).into(), + }) + .unwrap(); + } +} + +#[test] +fn fetch_resource_timing_publishes_once_after_buffered_and_streaming_body_completion() { + let document_url = Url::parse("https://fetch-timing.test/page").unwrap(); + let caller = document_url.join("caller.js").unwrap(); + for streaming in [false, true] { + let mut vm = new_storage_test_vm(document_url.as_str()); + vm.set_fetch_subresource_interception( + true, + Some(crate::types::SubresourceResourceType::Fetch), + ); + execute_with_source( + &mut vm, + r#" + globalThis.observed = []; + globalThis.observer = new PerformanceObserver(list => observed.push(...list.getEntries())); + observer.observe({type:'resource'}); + globalThis.fetchResponse = null; + globalThis.started = performance.now(); + fetch('/asset').then(response => fetchResponse = response); + "#, + &caller, + &document_url, + ); + let requests = vm.take_pending_subresource_fetch_infos(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + finish_fetch( + &mut vm, + request, + crate::protocol_types::NavigationResponse::from_text_body( + request.url.clone(), + 200, + vec![("Content-Type".to_owned(), b"text/plain".to_vec())], + "body".to_owned(), + ), + streaming, + ); + assert_eq!(vm.eval("String(fetchResponse.status)").unwrap(), "200"); + assert_eq!(vm.eval(r#"JSON.stringify({ + entries:performance.getEntriesByType('resource').map(entry => ({ + name:entry.name, type:entry.initiatorType, source:entry.initiatorUrl, + jsonSource:entry.toJSON().initiatorUrl, status:entry.responseStatus, + encoded:entry.encodedBodySize, decoded:entry.decodedBodySize, + transfer:entry.transferSize, native:entry instanceof PerformanceResourceTiming, + start:entry.startTime >= started && entry.startTime <= performance.now() + })), + observer:[...observed, ...observer.takeRecords()].map(entry => entry.name) + })"#).unwrap(), serde_json::json!({ + "entries":[{"name":request.url.as_str(),"type":"fetch","source":caller.as_str(), + "jsonSource":caller.as_str(),"status":200,"encoded":4,"decoded":4,"transfer":304,"native":true,"start":true}], + "observer":[request.url.as_str()] + }).to_string(), "streaming={streaming}"); + } +} + +#[tokio::test] +async fn fetch_resource_timing_retains_initiators_through_real_network_and_lazy_performance() { + let server = StaticHttpServer::spawn_with_responder( + 3, + "Content-Type: text/plain\r\nCache-Control: no-store\r\n", + |_, _| "ok".to_owned(), + ) + .await; + let base = server.base_url(); + let loader = static_http_loader(std::iter::empty::()); + let mut vm = new_storage_page_task_executor_test_vm_with_loader(base.as_str(), &loader); + execute_with_source( + &mut vm, + r#" + globalThis.fetchDone = 0; + globalThis.get = path => fetch(path).then(response => response.text()).then(() => fetchDone++); + "#, + &base.join("library.js").unwrap(), + &base, + ); + let caller = base.join("caller.js?version=2").unwrap(); + execute_with_source( + &mut vm, + r#" + get('/direct'); + Promise.resolve().then(() => get('/promise')); + setTimeout(() => get('/timer'), 0); + "#, + &caller, + &base, + ); + execute_with_source( + &mut vm, + "globalThis.laterScript = true;", + &base.join("later.js").unwrap(), + &base, + ); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(fetchDone)", + "3", + "real Fetch body completion", + ) + .await; + assert_eq!(vm.eval(r#"JSON.stringify(performance.getEntriesByType('resource') + .map(entry => [new URL(entry.name).pathname, entry.initiatorType, entry.initiatorUrl, entry.toJSON().initiatorUrl]).sort())"#).unwrap(), + serde_json::json!([ + ["/direct","fetch",caller.as_str(),caller.as_str()], + ["/promise","fetch",caller.as_str(),caller.as_str()], + ["/timer","fetch",caller.as_str(),caller.as_str()] + ]).to_string()); + assert_eq!(server.finish().await.len(), 3); +} + +#[test] +fn fetch_resource_timing_reports_failed_network_requests() { + let base = Url::parse("https://failed-fetch-timing.test/page").unwrap(); + let mut vm = new_storage_test_vm(base.as_str()); + vm.set_fetch_subresource_interception(true, Some(crate::types::SubresourceResourceType::Fetch)); + execute_with_source( + &mut vm, + "fetch('/failed').catch(error => globalThis.fetchError = error.name);", + &base, + &base, + ); + let request = vm.take_pending_subresource_fetch_infos().pop().unwrap(); + vm.complete_async_subresource_fetch(crate::types::AsyncSubresourceFetchCompletion { + internal_id: request.internal_id, + request_url: request.url.clone(), + request_method: request.method, + request_headers: request.request_headers, + request_body: None, + response_status_text: None, + skip_fetch_security_validation: false, + response_filter: None, + network_error_text: None, + result: Err("network failed".to_owned()).into(), + }) + .unwrap(); + assert_eq!(vm.eval("fetchError").unwrap(), "TypeError"); + assert_eq!( + vm.eval( + r#"JSON.stringify(performance.getEntriesByType('resource') + .map(entry => [entry.name,entry.initiatorType,entry.responseStatus,entry.transferSize]))"# + ) + .unwrap(), + serde_json::json!([[request.url.as_str(), "fetch", 0, 0]]).to_string() + ); +} + +#[test] +fn fetch_resource_timing_separates_cors_attribution_from_timing_allow_origin() { + let document = Url::parse("https://fetch-timing.test/page").unwrap(); + let caller = document.join("caller.js").unwrap(); + let streaming_modes: &[bool] = &[false, true]; + // The production transport buffers no-cors responses for ORB body sniffing. + let buffered: &[bool] = &[false]; + for (modes, mode, tao, expected_source, expected_size, expected_status) in [ + (streaming_modes, "cors", None, caller.as_str(), 0, 200), + (streaming_modes, "cors", Some("*"), caller.as_str(), 4, 200), + ( + streaming_modes, + "cors", + Some("https://fetch-timing.test"), + caller.as_str(), + 4, + 200, + ), + ( + streaming_modes, + "cors", + Some("https://other.test"), + caller.as_str(), + 0, + 200, + ), + (buffered, "no-cors", None, "", 0, 0), + (buffered, "no-cors", Some("*"), "", 4, 0), + ] { + for &streaming in modes { + let mut vm = new_storage_test_vm(document.as_str()); + vm.set_fetch_subresource_interception( + true, + Some(crate::types::SubresourceResourceType::Fetch), + ); + execute_with_source( + &mut vm, + &format!( + "fetch('https://cross-origin.test/asset', {{mode:'{mode}'}}).then(response => globalThis.fetchResponse = response);" + ), + &caller, + &document, + ); + let request = vm.take_pending_subresource_fetch_infos().pop().unwrap(); + let mut headers = vec![ + ("Content-Type".to_owned(), b"text/plain".to_vec()), + ("Access-Control-Allow-Origin".to_owned(), b"*".to_vec()), + ]; + if let Some(tao) = tao { + headers.push(("Timing-Allow-Origin".to_owned(), tao.as_bytes().to_vec())); + } + finish_fetch( + &mut vm, + &request, + crate::protocol_types::NavigationResponse::from_text_body( + request.url.clone(), + 200, + headers, + "body".to_owned(), + ), + streaming, + ); + assert_eq!( + vm.eval( + r#"JSON.stringify(performance.getEntriesByType('resource').map(entry => [ + entry.name, entry.initiatorType, entry.initiatorUrl, entry.responseStatus, + entry.encodedBodySize, entry.decodedBodySize, entry.transferSize, entry.contentType + ]))"# + ) + .unwrap(), + serde_json::json!([[ + request.url.as_str(), + "fetch", + expected_source, + expected_status, + expected_size, + expected_size, + if expected_size == 0 { 0 } else { 304 }, + if mode == "cors" { "text/plain" } else { "" } + ]]) + .to_string(), + "streaming={streaming}, mode={mode}, tao={tao:?}" + ); + } + } +} + +#[test] +fn fetch_resource_timing_keeps_cache_hits_and_unknown_script_sources_unattributed() { + let document = Url::parse("https://fetch-cache-timing.test/page").unwrap(); + let caller = document.join("caller.js").unwrap(); + for streaming in [false, true] { + for (cache, transfer) in [ + (moli_fetch::ResponseCacheState::None, 304), + (moli_fetch::ResponseCacheState::Local, 0), + (moli_fetch::ResponseCacheState::Validated, 300), + ] { + for known_source in [false, true] { + let mut vm = new_storage_test_vm(document.as_str()); + vm.set_fetch_subresource_interception( + true, + Some(crate::types::SubresourceResourceType::Fetch), + ); + let script = + "fetch('/asset').then(response => globalThis.fetchResponse = response);"; + if known_source { + execute_with_source(&mut vm, script, &caller, &document); + } else { + vm.exec(script, None).unwrap(); + } + let request = vm.take_pending_subresource_fetch_infos().pop().unwrap(); + let mut response = crate::protocol_types::NavigationResponse::from_text_body( + request.url.clone(), + 200, + Vec::new(), + "body".to_owned(), + ); + response.cache_state = cache; + response.from_cache = cache != moli_fetch::ResponseCacheState::None; + finish_fetch(&mut vm, &request, response, streaming); + let source = if known_source && cache == moli_fetch::ResponseCacheState::None { + caller.as_str() + } else { + "" + }; + assert_eq!( + vm.eval( + r#"JSON.stringify(performance.getEntriesByType('resource').map(entry => [ + entry.initiatorUrl, entry.encodedBodySize, entry.transferSize + ]))"# + ) + .unwrap(), + serde_json::json!([[source, 4, transfer]]).to_string(), + "streaming={streaming}, cache={cache:?}, known_source={known_source}" + ); + } + } + } +} diff --git a/moli-renderer-v8/src/types.rs b/moli-renderer-v8/src/types.rs index 9320bada05..68cc9614c0 100644 --- a/moli-renderer-v8/src/types.rs +++ b/moli-renderer-v8/src/types.rs @@ -246,6 +246,7 @@ impl PendingSubresourceContinuation { pub(super) struct PendingWindowFetchContinuation { promise: PendingWindowFetchPromise, + resource_timing: crate::context_bootstrap::FetchResourceTiming, keepalive: bool, cache_mode: moli_fetch::RequestCacheMode, connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, @@ -260,6 +261,7 @@ enum PendingWindowFetchPromise { impl PendingWindowFetchContinuation { pub(super) fn new( resolver: v8::Global, + resource_timing: crate::context_bootstrap::FetchResourceTiming, keepalive: bool, cache_mode: moli_fetch::RequestCacheMode, connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot, @@ -267,6 +269,7 @@ impl PendingWindowFetchContinuation { ) -> Self { Self { promise: PendingWindowFetchPromise::Active(resolver), + resource_timing, keepalive, cache_mode, connect_policy, @@ -278,6 +281,10 @@ impl PendingWindowFetchContinuation { self.cache_mode } + pub(super) fn resource_timing(&self) -> &crate::context_bootstrap::FetchResourceTiming { + &self.resource_timing + } + pub(super) fn keepalive(&self) -> bool { self.keepalive }