mirror of
https://github.com/lexmount/moli.git
synced 2026-10-31 00:06:19 +00:00
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.
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<f64>,
|
||||
end_unix_millis: Option<f64>,
|
||||
response_start_unix_millis: Option<f64>,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
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<u8>)],
|
||||
) -> 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::<Vec<_>>()
|
||||
.join(", ");
|
||||
moli_fetch::split_http_header_list(&value)
|
||||
.any(|value| value == "*" || value == serialized_origin)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<v8::Context>,
|
||||
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<crate::structured_clone::V8StructuredClonePayload>,
|
||||
) -> bool {
|
||||
) -> Option<(
|
||||
v8::Global<v8::Context>,
|
||||
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<v8::Context>,
|
||||
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),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String>,
|
||||
gates: HashMap<String, Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
|
||||
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<Mutex<TimingServerState>>,
|
||||
) {
|
||||
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::<u16>()
|
||||
.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!([]));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)});
|
||||
})();
|
||||
Reference in New Issue
Block a user