diff --git a/Cargo.lock b/Cargo.lock index 5ec692a..0b1e873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10632,6 +10632,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls-pki-types", "serde", + "serde_bytes", "serde_json", "servo-base", "servo-config", diff --git a/crates/beaver_shell/src/main.rs b/crates/beaver_shell/src/main.rs index ed5ad05..d2d8ad4 100644 --- a/crates/beaver_shell/src/main.rs +++ b/crates/beaver_shell/src/main.rs @@ -89,7 +89,7 @@ fn load_servo_prefs(config_dir: Option) -> Preferences { }; // Try to load saved preferences and merge on top. - if let Some(path) = config_dir.map(|d| d.join("servo_prefs.json")) { + if let Some(path) = config_dir.as_ref().map(|d| d.join("servo_prefs.json")) { if let Ok(content) = std::fs::read_to_string(&path) { match serde_json::from_str::(&content) { Ok(saved) => { @@ -103,6 +103,21 @@ fn load_servo_prefs(config_dir: Option) -> Preferences { } } + // Turn on the on-disk HTTP cache. + if let Some(dir) = config_dir.as_ref() { + // rusqlite::Connection::open creates the database file but not its parent + // directory, be safe and ensure the full directory path is created. + if let Err(e) = std::fs::create_dir_all(dir) { + warn!("Could not create {}: {e}", dir.display()); + } + preferences.network_http_disk_cache = + dir.join("http_cache.db").to_string_lossy().into_owned(); + info!( + "HTTP disk cache: {} (max {} bytes)", + preferences.network_http_disk_cache, preferences.network_http_disk_cache_size + ); + } + preferences } diff --git a/patches/components/net/async_runtime.rs.patch b/patches/components/net/async_runtime.rs.patch index 222ddce..2a6365e 100644 --- a/patches/components/net/async_runtime.rs.patch +++ b/patches/components/net/async_runtime.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -81,6 +81,20 @@ +@@ -81,6 +81,34 @@ .spawn(task); } @@ -17,6 +17,20 @@ + .expect("Runtime handle should be initialized on start-up") + .spawn_blocking(task); +} ++ ++/// As [`spawn_blocking`], but returns a handle so the caller can await the result. ++/// Use it when blocking work sits on a path that needs its answer, so the worker is ++/// released for the duration instead of being held inside the blocking call. ++pub fn spawn_blocking_with_result(task: F) -> tokio::task::JoinHandle ++where ++ F: FnOnce() -> R + Send + 'static, ++ R: Send + 'static, ++{ ++ ASYNC_RUNTIME_HANDLE ++ .get() ++ .expect("Runtime handle should be initialized on start-up") ++ .spawn_blocking(task) ++} + /// Spawn a blocking task using the handle to the runtime. pub fn spawn_blocking_task(task: F) -> F::Output diff --git a/patches/components/net/atproto/xrpc.rs.patch b/patches/components/net/atproto/xrpc.rs.patch index d262e10..9974681 100644 --- a/patches/components/net/atproto/xrpc.rs.patch +++ b/patches/components/net/atproto/xrpc.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,296 @@ +@@ -0,0 +1,301 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_recursion::async_recursion; @@ -28,6 +28,9 @@ + context: FetchContext, +} + ++/// Blob fetches are content-addressed: the `cid` query parameter is a hash of the bytes. ++const GET_BLOB_PATH: &str = "/xrpc/com.atproto.sync.getBlob"; ++ +impl XrpcClient { + pub fn new( + endpoint: ServoUrl, @@ -80,7 +83,12 @@ + .method(method.unwrap_or(Method::GET)) + .headers(request_headers) + .origin(xrpc_url.origin()) -+ .body(body); ++ .body(body) ++ // PDS implementations send no useful caching headers for blobs, so without ++ // this the HTTP cache refetches every avatar and feed image on every view. ++ .min_freshness_lifetime( ++ (xrpc_call == GET_BLOB_PATH).then_some(crate::http_cache::IMMUTABLE_FRESHNESS_LIFETIME), ++ ); + (builder.build(), xrpc_url) + } + @@ -226,10 +234,7 @@ + // If the collection name is "com.atproto.sync.blob", this is a link to a blob. + if collection == "com.atproto.sync.blob" { + return self -+ .fetch_get( -+ "/xrpc/com.atproto.sync.getBlob", -+ &[("did", &self.repo_did), ("cid", rkey)], -+ ) ++ .fetch_get(GET_BLOB_PATH, &[("did", &self.repo_did), ("cid", rkey)]) + .await; + } + diff --git a/patches/components/net/disk_cache.rs.patch b/patches/components/net/disk_cache.rs.patch new file mode 100644 index 0000000..fd6adf6 --- /dev/null +++ b/patches/components/net/disk_cache.rs.patch @@ -0,0 +1,278 @@ +--- original ++++ modified +@@ -4,12 +4,14 @@ + + use std::collections::VecDeque; + use std::sync::Arc; +-use std::time::{SystemTime, UNIX_EPOCH}; ++use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use log::error; + use malloc_size_of_derive::MallocSizeOf; + use rusqlite::Row; +-use sea_query::{ColumnDef, Expr, ExprTrait, Iden, OnConflict, Query, SqliteQueryBuilder, Table}; ++use sea_query::{ ++ ColumnDef, Expr, ExprTrait, Iden, OnConflict, Order, Query, SqliteQueryBuilder, Table, ++}; + use sea_query_rusqlite::RusqliteBinder; + use servo_config::pref; + use servo_url::ServoUrl; +@@ -100,7 +102,18 @@ + return (None, MemoryCacheLifecycle::empty()); + }; + +- let _ = db.execute("PRAGMA journal_mode = WAL;", ()); ++ // `execute` returns Err for a pragma (it yields a row), which is why the ++ // original swallowed the result; `pragma_update` handles that properly. ++ // `synchronous = NORMAL` matters most: at the FULL default, WAL fsyncs on ++ // every commit, and write-through commits once per response. ++ let pragmas = db ++ .pragma_update(None, "journal_mode", "WAL") ++ .and_then(|()| db.pragma_update(None, "synchronous", "NORMAL")) ++ .and_then(|()| db.busy_timeout(Duration::from_secs(30))); ++ if let Err(e) = pragmas { ++ error!("Could not configure the disk cache database: {e}"); ++ return (None, MemoryCacheLifecycle::empty()); ++ } + let query = Table::create() + .table(DiskCacheTable::Table) + .if_not_exists() +@@ -126,6 +139,8 @@ + let (query, values) = Query::select() + .columns([DiskCacheTable::Key, DiskCacheTable::Size]) + .from(DiskCacheTable::Table) ++ // Restore the deque in the order eviction wants to drain it. ++ .order_by(DiskCacheTable::InsertionTimestamp, Order::Asc) + .build_rusqlite(SqliteQueryBuilder); + + let (entries, size) = { +@@ -163,59 +178,45 @@ + } + } + +- /// Restores a cache entry from the disk if it exists. +- /// Deletes the entry from the disk cache ++ /// Restores a cache entry from the disk if it exists. The row is ++ /// left in place: moving it out would leave the only copy in memory ++ /// and make every cold hit a DELETE plus a re-INSERT. ++ /// Runs the sqlite read and the deserialize on the blocking pool: this sits on ++ /// the fetch path, so holding an async worker across them would stall unrelated ++ /// requests rather than just this one. + #[servo_tracing::instrument(skip(self))] +- pub(crate) async fn get(&self, key: CacheKey) -> Option>>> { +- let bytes = { +- // we lock the metadata before we update the sqlite database so that +- // the database and metadata are consistent when this lock is released. +- let mut inner = self.inner.lock().await; +- let (bytes, new_size) = { +- let _span = profile_traits::trace_span!("query disk cache").entered(); +- let (query, query_values) = Query::select() +- .columns([DiskCacheTable::Data]) +- .from(DiskCacheTable::Table) +- .and_where(Expr::col(DiskCacheTable::Key).eq(key.as_ref())) +- .build_rusqlite(SqliteQueryBuilder); +- let (delete, delete_values) = Query::delete() +- .from_table(DiskCacheTable::Table) +- .and_where(Expr::col(DiskCacheTable::Key).eq(key.as_ref())) +- .build_rusqlite(SqliteQueryBuilder); ++ pub(crate) async fn get( ++ self: &Arc, ++ key: CacheKey, ++ ) -> Option>>> { ++ let this = self.clone(); ++ crate::async_runtime::spawn_blocking_with_result(move || this.get_blocking(key)) ++ .await ++ .unwrap_or_else(|e| { ++ error!("Disk cache read panicked: {e}"); ++ None ++ }) ++ } + +- let mut st = inner.db.prepare(query.as_str()).ok()?; +- let data: Vec = st +- .query_one(&*query_values.as_params(), |row| Ok(row.get_unwrap("data"))) +- .ok()?; ++ fn get_blocking(&self, key: CacheKey) -> Option>>> { ++ let bytes: Vec = { ++ let inner = self.inner.blocking_lock(); + +- if inner +- .db +- .execute(delete.as_str(), &*delete_values.as_params()) +- .is_err() +- { +- error!("Could not delete cached data from disk"); +- return None; +- } ++ // `entries` mirrors the table so a miss here means there is no row. ++ if !inner.entries.iter().any(|metadata| metadata.key == key) { ++ return None; ++ } + +- (data, self.get_disk_cache_total_size(&inner.db)) +- }; ++ let _span = profile_traits::trace_span!("query disk cache").entered(); ++ let (query, query_values) = Query::select() ++ .columns([DiskCacheTable::Data]) ++ .from(DiskCacheTable::Table) ++ .and_where(Expr::col(DiskCacheTable::Key).eq(key.as_ref())) ++ .build_rusqlite(SqliteQueryBuilder); + +- { +- // update the metadata +- let entry_index = inner +- .entries +- .iter() +- .position(|metadata| metadata.key == key); +- if let Some(entry_index) = entry_index { +- inner.entries.remove(entry_index); +- } +- if let Some(new_size) = new_size { +- inner.size = new_size; +- } else { +- error!("Could not get disk cache size"); +- } +- } +- bytes ++ let mut st = inner.db.prepare(query.as_str()).ok()?; ++ st.query_one(&*query_values.as_params(), |row| Ok(row.get_unwrap("data"))) ++ .ok()? + }; + let _span = profile_traits::trace_span!("deserialize cache request").entered(); + let Ok(value) = postcard::from_bytes(&bytes) else { +@@ -229,12 +230,29 @@ + + /// Stores a [`CacheEntry`]` to disk. + #[servo_tracing::instrument(skip(self))] +- pub(crate) async fn store(&self, key: CacheKey, entry: CacheEntry) { +- let entry = entry.read().await; ++ /// Runs on the blocking pool (see `spawn_disk_store`), so it takes the locks ++ /// with their blocking variants rather than awaiting them. ++ pub(crate) fn store(&self, key: CacheKey, entry: CacheEntry) { ++ let entry = entry.blocking_read(); + let data_to_serialize: Vec<&CachedResource> = entry + .iter() + .filter(|cached_resource| cached_resource.is_done()) + .collect(); ++ if data_to_serialize.is_empty() { ++ // Fires for every completed response, including one whose body is still ++ // arriving for another consumer; an empty row would overwrite a good one. ++ return; ++ } ++ // Callers fire on every completed fetch, cache hits included, so without this ++ // a warm reload re-serializes and rewrites every already-persisted body. The ++ // whole entry goes into one row, so it is all-or-nothing: writing only the ++ // dirty resources would drop their clean siblings from disk. ++ if !data_to_serialize ++ .iter() ++ .any(|cached_resource| cached_resource.needs_persisting()) ++ { ++ return; ++ } + let Ok(data) = postcard::to_stdvec(&*data_to_serialize) else { + error!("Could not deserialize value"); + return; +@@ -241,7 +259,7 @@ + }; + + { +- let mut inner = self.inner.lock().await; ++ let mut inner = self.inner.blocking_lock(); + let data_size = data.len(); + + let timestamp = SystemTime::now() +@@ -260,7 +278,6 @@ + OnConflict::column(DiskCacheTable::Key) + .update_columns([ + DiskCacheTable::Data, +- DiskCacheTable::Data, + DiskCacheTable::Size, + DiskCacheTable::InsertionTimestamp, + ]) +@@ -276,30 +293,44 @@ + + if let Err(e) = inner.db.execute(query.as_str(), &*params.as_params()) { + error!("Could not insert cache data. Error {}", e); ++ return; ++ } ++ for cached_resource in &data_to_serialize { ++ cached_resource.mark_persisted(); + } ++ // One row per key (the insert is an upsert), so replace any existing ++ // metadata rather than appending a duplicate that would over-count. ++ if let Some(index) = inner.entries.iter().position(|entry| entry.key == key) { ++ inner.size -= inner.entries.remove(index).map_or(0, |entry| entry.size); ++ } + inner.entries.push_back(DiskCacheMetadata { + key, + size: data_size, + }); +- if let Some(new_cache_size) = self.get_disk_cache_total_size(&inner.db) { +- inner.size = new_cache_size; +- } ++ inner.size += data_size; ++ ++ Self::delete_until_cache_size(&mut inner, self.max_size); + } +- self.delete_until_cache_size().await; + } + +- /// Deletes data from the cache until the size is <= max_size +- #[servo_tracing::instrument(skip(self))] +- async fn delete_until_cache_size(&self) { +- let mut inner = self.inner.lock().await; ++ /// Deletes data from the cache until the size is <= max_size. Takes the guard ++ /// rather than locking, so `store` does it in one critical section. ++ fn delete_until_cache_size(inner: &mut DiskCacheInner, max_size: usize) { + let mut keys_to_delete = vec![]; +- while self.max_size < inner.size { +- if let Some(metadata) = inner.entries.pop_back() { +- keys_to_delete.push(metadata.key); +- inner.size -= metadata.size; +- } ++ while max_size < inner.size { ++ // Oldest first. `store` pushes the back, so popping the back would evict ++ // the row just written and the cache would never admit anything again. ++ let Some(metadata) = inner.entries.pop_front() else { ++ break; ++ }; ++ keys_to_delete.push(metadata.key); ++ inner.size = inner.size.saturating_sub(metadata.size); + } + ++ if keys_to_delete.is_empty() { ++ return; ++ } ++ + let keys_ref = keys_to_delete.iter().map(|key| key.as_ref()); + let (query, values) = Query::delete() + .from_table(DiskCacheTable::Table) +@@ -315,28 +346,6 @@ + } + } + +- /// Queries the current disk cache size from the sql database. +- #[servo_tracing::instrument(skip(self))] +- fn get_disk_cache_total_size(&self, conn: &rusqlite::Connection) -> Option { +- let (size, size_values) = Query::select() +- .expr(Expr::col(DiskCacheTable::Size).sum()) +- .from(DiskCacheTable::Table) +- .build_rusqlite(SqliteQueryBuilder); +- let Ok(mut st) = conn.prepare(size.as_str()) else { +- return None; +- }; +- +- // According to the sqlite documentation we will return NULL on an empty table. +- let query_result = +- st.query_one(&*size_values.as_params(), |row| Ok(row.get(0).unwrap_or(0))); +- if let Err(query_result) = query_result { +- error!("Could nto get new sum size {}", query_result); +- None +- } else { +- query_result.ok() +- } +- } +- + /// Clears the disk cache. + /// Should only be called in sync context and will panic. + #[servo_tracing::instrument(skip(self))] diff --git a/patches/components/net/http_cache.rs.patch b/patches/components/net/http_cache.rs.patch new file mode 100644 index 0000000..b399d3d --- /dev/null +++ b/patches/components/net/http_cache.rs.patch @@ -0,0 +1,219 @@ +--- original ++++ modified +@@ -36,6 +36,11 @@ + use crate::disk_cache::DiskCache; + use crate::fetch::methods::{Data, DoneChannel}; + ++/// Freshness for a response marked `Cache-Control: immutable` ++/// () that carries no explicit `max-age`. ++/// A year is the cap, per . ++pub(crate) const IMMUTABLE_FRESHNESS_LIFETIME: Duration = Duration::from_secs(365 * 24 * 60 * 60); ++ + /// The key used to differentiate requests in the cache. + #[derive(Clone, Debug, Eq, Hash, MallocSizeOf, PartialEq)] + pub struct CacheKey { +@@ -84,6 +89,10 @@ + #[serde(skip)] + revalidating: StdArc, + last_validated: SystemTime, ++ /// Whether this resource holds changes the disk cache does not have yet. ++ #[conditional_malloc_size_of] ++ #[serde(skip)] ++ needs_persisting: StdArc, + } + + impl CachedResource { +@@ -90,6 +99,14 @@ + pub(crate) fn is_done(&self) -> bool { + self.body.lock().is_done() + } ++ ++ pub(crate) fn needs_persisting(&self) -> bool { ++ self.needs_persisting.load(Ordering::Acquire) ++ } ++ ++ pub(crate) fn mark_persisted(&self) { ++ self.needs_persisting.store(false, Ordering::Release); ++ } + } + + #[derive(Debug, Deserialize, MallocSizeOf, Serialize)] +@@ -247,13 +264,25 @@ + fn begin_request(&self) -> Self::RequestState {} + + fn on_evict(&self, _state: &mut Self::RequestState, key: CacheKey, value: CacheEntry) { +- if let Some(disk_cache_data) = &self.disk_cache { +- let disk_cache_data = disk_cache_data.clone(); +- tokio::spawn(async move { disk_cache_data.store(key, value).await }); +- } ++ spawn_disk_store(self.disk_cache.as_ref(), key, value); + } + } + ++/// Persist an entry to the disk cache, if there is one. ++/// ++/// On the blocking pool, not the async workers: [`DiskCache::store`] serializes the ++/// whole entry and writes a blob, and the `net` runtime has few workers shared with ++/// every connection, so blocking one per response would starve unrelated fetches. ++fn spawn_disk_store( ++ disk_cache: Option<&std::sync::Arc>, ++ key: CacheKey, ++ entry: CacheEntry, ++) { ++ if let Some(disk_cache) = disk_cache.cloned() { ++ crate::async_runtime::spawn_blocking(move || disk_cache.store(key, entry)); ++ } ++} ++ + /// Determine if a response is cacheable by default + fn is_cacheable_by_default(status_code: StatusCode) -> bool { + matches!( +@@ -264,7 +293,7 @@ + + /// Determine if a given response is cacheable. + /// Based on +-fn response_is_cacheable(metadata: &Metadata) -> bool { ++fn response_is_cacheable(metadata: &Metadata, min_freshness: Option) -> bool { + // TODO: if we determine that this cache should be considered shared: + // 1. check for absence of private response directive + // 2. check for absence of the Authorization header field. +@@ -283,6 +312,7 @@ + if directive.public() || + directive.s_max_age().is_some() || + directive.max_age().is_some() || ++ directive.immutable() || + directive.no_cache() + { + // If cache-control is understood, we can use it and ignore pragma. +@@ -294,7 +324,9 @@ + { + return false; + } +- is_cacheable ++ // A `min_freshness_lifetime` caller vouches for the URL, so a missing validator ++ // is no reason not to store the response. ++ is_cacheable || (min_freshness.is_some() && metadata.status.is_success()) + } + + /// Calculating Age +@@ -312,7 +344,7 @@ + + /// Determine the expiry date from relevant headers, + /// or uses a heuristic if none are present. +-fn get_response_expiry(response: &Response) -> Duration { ++fn get_response_expiry(response: &Response, min_freshness: Option) -> Duration { + // Calculating Freshness Lifetime + let age = calculate_response_age(response); + let now = SystemTime::now(); +@@ -324,6 +356,10 @@ + if let Some(max_age) = directives.max_age().or(directives.s_max_age()) { + return max_age.saturating_sub(age); + } ++ // ++ if directives.immutable() { ++ return IMMUTABLE_FRESHNESS_LIFETIME.saturating_sub(age); ++ } + } + match response.headers.typed_get::() { + Some(expiry) => { +@@ -336,6 +372,15 @@ + None if response.headers.contains_key(header::EXPIRES) => return Duration::ZERO, + _ => {}, + } ++ // Deliberately after `no-cache`, `max-age` and `Expires`: an origin that states ++ // a policy wins over the caller's floor, and silence loses to it. See ++ // `Request::min_freshness_lifetime`. ++ if let Some(min_freshness) = min_freshness && ++ response.status.is_success() ++ { ++ return min_freshness.saturating_sub(age); ++ } ++ + // Calculating Heuristic Freshness + // + if let Some(ref code) = response.status.try_code() { +@@ -501,6 +546,8 @@ + // TODO: take must-revalidate into account + // TODO: if this cache is to be considered shared, take proxy-revalidate into account + // ++ // TODO: a response marked `immutable` should also suppress the conditional ++ // request a reload forces . + let has_expired = adjusted_expires <= time_since_validated; + + // - fresh: return immediately, no validation. +@@ -546,6 +593,8 @@ + stale_while_revalidate: resource.stale_while_revalidate, + revalidating: resource.revalidating.clone(), + last_validated: resource.last_validated, ++ // Derived here, so not on disk whatever its parent's state was. ++ needs_persisting: StdArc::new(AtomicBool::new(true)), + } + } + +@@ -888,10 +937,14 @@ + stored_headers.extend(response.headers); + constructed_response.headers = stored_headers.clone(); + } +- cached_resource.expires = get_response_expiry(constructed_response); ++ cached_resource.expires = ++ get_response_expiry(constructed_response, request.min_freshness_lifetime); + cached_resource.stale_while_revalidate = + get_stale_while_revalidate(&constructed_response.headers); + cached_resource.last_validated = SystemTime::now(); ++ cached_resource ++ .needs_persisting ++ .store(true, Ordering::Release); + } + + constructed_response +@@ -916,9 +969,10 @@ + } + + impl HttpCache { +- /// Wake-up consumers of cached resources +- /// whose response body was still receiving data when the resource was constructed, +- /// and whose response has now either been completed or cancelled. ++ /// Called once a response is complete. Wakes up consumers of cached resources ++ /// whose response body was still receiving data when the resource was ++ /// constructed, and whose response has now either been completed or cancelled, ++ /// then writes the entry through to the disk cache. + pub(crate) async fn update_awaiting_consumers(&self, request: &Request, response: &Response) { + let entry_key = CacheKey::new(request); + +@@ -962,6 +1016,15 @@ + let _ = done_sender.send(to_send.clone()); + } + } ++ ++ // Store here rather than at `insert` time: a body only becomes `Done` after ++ // `process_response_eof`, and `on_evict` alone would persist nothing until ++ // the memory cache overflows. ++ spawn_disk_store( ++ self.disk_cache.as_ref(), ++ entry_key, ++ cached_resources.clone(), ++ ); + } + + /// Returns descriptors for cache entries currently stored in this cache. +@@ -1102,10 +1165,10 @@ + Ok(FetchMetadata::Unfiltered(metadata)) => metadata, + _ => return, + }; +- if !response_is_cacheable(&metadata) { ++ if !response_is_cacheable(&metadata, request.min_freshness_lifetime) { + return; + } +- let expiry = get_response_expiry(response); ++ let expiry = get_response_expiry(response, request.min_freshness_lifetime); + let stale_while_revalidate = get_stale_while_revalidate(&response.headers); + let cacheable_metadata = CachedMetadata { + headers: Arc::new(ParkingLotMutex::new(response.headers.clone().into())), +@@ -1127,6 +1190,7 @@ + stale_while_revalidate, + revalidating: StdArc::new(AtomicBool::new(false)), + last_validated: SystemTime::now(), ++ needs_persisting: StdArc::new(AtomicBool::new(true)), + }; + + match self { diff --git a/patches/components/net/tests/http_cache.rs.patch b/patches/components/net/tests/http_cache.rs.patch new file mode 100644 index 0000000..ee62f2a --- /dev/null +++ b/patches/components/net/tests/http_cache.rs.patch @@ -0,0 +1,43 @@ +--- original ++++ modified +@@ -225,3 +225,40 @@ + "a no-cache request must trigger synchronous validation, not background revalidation" + ); + } ++ ++#[tokio::test] ++async fn test_immutable_response_is_fresh() { ++ let validation_status = stale_while_revalidate_freshness_for_cache_control("immutable").await; ++ assert_eq!( ++ validation_status, ++ ValidationStatus::Valid, ++ "an immutable response should be served without revalidation \ ++ " ++ ); ++} ++ ++#[tokio::test] ++async fn test_immutable_does_not_override_an_explicit_max_age() { ++ let validation_status = ++ stale_while_revalidate_freshness_for_cache_control("immutable, max-age=0").await; ++ assert_eq!( ++ validation_status, ++ ValidationStatus::Stale { ++ revalidate_in_background: false ++ }, ++ "an explicit max-age still bounds the freshness lifetime of an immutable response" ++ ); ++} ++ ++#[tokio::test] ++async fn test_no_cache_wins_over_immutable() { ++ let validation_status = ++ stale_while_revalidate_freshness_for_cache_control("immutable, no-cache").await; ++ assert_eq!( ++ validation_status, ++ ValidationStatus::Stale { ++ revalidate_in_background: false ++ }, ++ "no-cache requires validation on first use even alongside immutable" ++ ); ++} diff --git a/patches/components/net/tests/main.rs.patch b/patches/components/net/tests/main.rs.patch new file mode 100644 index 0000000..611c78f --- /dev/null +++ b/patches/components/net/tests/main.rs.patch @@ -0,0 +1,12 @@ +--- original ++++ modified +@@ -123,6 +123,9 @@ + )), + override_manager, + embedder_proxy: fc.unwrap_or_else(|| create_generic_embedder_proxy()), ++ config_dir: None, ++ atproto_session: RwLock::new(None), ++ web_tiles: Default::default(), + } + } + diff --git a/patches/components/script/dom/window/window.rs.patch b/patches/components/script/dom/window/window.rs.patch index d8ab37b..ba1edf8 100644 --- a/patches/components/script/dom/window/window.rs.patch +++ b/patches/components/script/dom/window/window.rs.patch @@ -143,7 +143,7 @@ /// fn Screen(&self, cx: &mut JSContext) -> DomRoot { self.screen.or_init(|| Screen::new(cx, self)) -@@ -2730,6 +2784,10 @@ +@@ -2731,6 +2785,10 @@ accessibility_damage = Some(accessibility_data.drain_pending_accessibility_damage()); } @@ -154,7 +154,7 @@ // Send new document and relevant styles to layout. let reflow = ReflowRequest { document: document.upcast::().to_trusted_node_address(), -@@ -3240,10 +3298,33 @@ +@@ -3241,10 +3299,33 @@ flags: HitTestFlags, input_event: &ConstellationInputEvent, ) -> Option { @@ -192,7 +192,7 @@ } #[expect(unsafe_code)] -@@ -3264,8 +3345,42 @@ +@@ -3265,8 +3346,42 @@ let address = UntrustedNodeAddress(node.0 as *const c_void); unsafe { from_untrusted_node_address(address) } }; @@ -236,7 +236,7 @@ dom_position_for_selection: result .dom_position_for_selection .map(|(node, offset)| (from_opaque_node(node), offset)), -@@ -3639,17 +3754,19 @@ +@@ -3640,17 +3755,19 @@ } pub(crate) fn send_to_embedder(&self, msg: EmbedderMsg) { @@ -262,7 +262,7 @@ } #[cfg(feature = "webxr")] -@@ -3882,6 +3999,8 @@ +@@ -3883,6 +4000,8 @@ player_context: WindowGLContext, #[cfg(feature = "webgpu")] gpu_id_hub: Arc, inherited_secure_context: Option, @@ -271,7 +271,7 @@ theme: Theme, weak_script_thread: Weak, ) -> DomRoot { -@@ -3906,6 +4025,8 @@ +@@ -3907,6 +4026,8 @@ gpu_id_hub, inherited_secure_context, unminify_js, @@ -280,7 +280,7 @@ ), caches: Default::default(), ongoing_navigation: Default::default(), -@@ -4175,3 +4296,114 @@ +@@ -4176,3 +4297,114 @@ Window::origin(self) } } diff --git a/patches/components/shared/net/Cargo.toml.patch b/patches/components/shared/net/Cargo.toml.patch index a4d1464..62dd009 100644 --- a/patches/components/shared/net/Cargo.toml.patch +++ b/patches/components/shared/net/Cargo.toml.patch @@ -1,9 +1,10 @@ --- original +++ modified -@@ -44,12 +44,15 @@ +@@ -44,12 +44,16 @@ rustc-hash = { workspace = true } rustls-pki-types = { workspace = true } serde = { workspace = true } ++serde_bytes = { workspace = true } +serde_json = { workspace = true } servo-base = { workspace = true } servo-config = { workspace = true } diff --git a/patches/components/shared/net/request.rs.patch b/patches/components/shared/net/request.rs.patch new file mode 100644 index 0000000..29842cd --- /dev/null +++ b/patches/components/shared/net/request.rs.patch @@ -0,0 +1,61 @@ +--- original ++++ modified +@@ -519,6 +519,8 @@ + pub crash: Option, + /// Servo internal: whether this request originates from Servo internal implementation + pub is_internal_request: InternalRequest, ++ /// See [`Request::min_freshness_lifetime`]. ++ pub min_freshness_lifetime: Option, + } + + impl RequestBuilder { +@@ -561,6 +563,7 @@ + initiator: Initiator::None, + response_tainting: ResponseTainting::Basic, + is_internal_request: Default::default(), ++ min_freshness_lifetime: None, + crash: None, + } + } +@@ -723,6 +726,15 @@ + self + } + ++ /// See [`Request::min_freshness_lifetime`]. ++ pub fn min_freshness_lifetime( ++ mut self, ++ lifetime: Option, ++ ) -> RequestBuilder { ++ self.min_freshness_lifetime = lifetime; ++ self ++ } ++ + pub fn is_internal_request(mut self, is_internal_request: InternalRequest) -> RequestBuilder { + self.is_internal_request = is_internal_request; + self +@@ -774,6 +786,7 @@ + request.client = self.client; + request.policy_container = self.policy_container; + request.is_internal_request = self.is_internal_request; ++ request.min_freshness_lifetime = self.min_freshness_lifetime; + request + } + +@@ -862,6 +875,9 @@ + pub crash: Option, + /// Servo internal: whether this request originates from Servo internal implementation + pub is_internal_request: InternalRequest, ++ /// Treat the response as fresh for at least this long, and store it ++ /// even when the origin sent no validator; only an explicit `no-store` wins. ++ pub min_freshness_lifetime: Option, + } + + impl Request { +@@ -909,6 +925,7 @@ + response_tainting: ResponseTainting::Basic, + policy_container: RequestPolicyContainer::Client, + is_internal_request: Default::default(), ++ min_freshness_lifetime: None, + crash: None, + } + } diff --git a/patches/components/shared/net/response.rs.patch b/patches/components/shared/net/response.rs.patch new file mode 100644 index 0000000..a2498ec --- /dev/null +++ b/patches/components/shared/net/response.rs.patch @@ -0,0 +1,12 @@ +--- original ++++ modified +@@ -46,7 +46,9 @@ + #[derive(Clone, Debug, Deserialize, MallocSizeOf, PartialEq, Serialize)] + pub enum ResponseBody { + Empty, // XXXManishearth is this necessary, or is Done(vec![]) enough? ++ #[serde(with = "serde_bytes")] + Receiving(Vec), ++ #[serde(with = "serde_bytes")] + Done(Vec), + } +