first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+151 -12
View File
@@ -1,18 +1,23 @@
use anyhow::anyhow;
use anyhow::{Error, Result};
use std::any::{Any, TypeId};
use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::Hash;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::time::SystemTime;
use anyhow::{anyhow, Error, Result};
use async_channel::{self, Receiver, Sender};
use bytes::Bytes;
use derivative::Derivative;
use futures::FutureExt as _;
use futures::{future::BoxFuture, Future};
use std::any::{Any, TypeId};
use std::pin::Pin;
use std::{cell::RefCell, collections::HashMap, hash::Hash, rc::Rc, sync::Arc};
use crate::image_cache::ImageCache;
use crate::{r#async::executor, Entity, ModelContext, SingletonEntity};
use futures::future::BoxFuture;
use futures::{Future, FutureExt as _};
use super::AssetProvider;
use crate::image_cache::ImageCache;
use crate::r#async::executor;
use crate::{Entity, ModelContext, SingletonEntity};
pub trait FetchAsset: crate::r#async::Spawnable + Future<Output = Result<Bytes>> {}
impl<T: crate::r#async::Spawnable + Future<Output = Result<Bytes>> + ?Sized> FetchAsset for T {}
@@ -60,6 +65,41 @@ impl std::fmt::Debug for AsyncAssetId {
}
}
/// A content fingerprint for a local file on disk.
///
/// Used as part of an [`AssetSource::LocalFile`] cache key so that a file whose
/// contents change on disk is treated as a distinct asset and re-read, rather
/// than served from a now-stale cache entry.
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
pub struct LocalFileContentVersion {
modified: Option<SystemTime>,
file_size: u64,
}
impl LocalFileContentVersion {
/// Builds a content version by reading filesystem metadata for `path`.
///
/// Performs blocking filesystem I/O, so this must only be called off the
/// render hot path (for example, once when a view resolves its image
/// sources), never on every frame. Returns `None` when metadata cannot be
/// read.
#[cfg(not(target_arch = "wasm32"))]
pub fn for_path(path: impl AsRef<std::path::Path>) -> Option<Self> {
let metadata = std::fs::metadata(path).ok()?;
Some(Self {
modified: metadata.modified().ok(),
file_size: metadata.len(),
})
}
/// Filesystem metadata is unavailable on WASM, so a local-file content
/// version is never computed there.
#[cfg(target_arch = "wasm32")]
pub fn for_path(_path: impl AsRef<std::path::Path>) -> Option<Self> {
None
}
}
/// A "URI" for some data file. In other words, the location of an asset.
#[derive(Derivative)]
#[derivative(Clone, Hash, PartialEq, Eq, Debug)]
@@ -81,11 +121,39 @@ pub enum AssetSource {
path: &'static str,
},
/// Accessible in the user's local filesystem at the provided path.
LocalFile { path: String },
LocalFile {
path: String,
/// Optional content fingerprint. When present, it makes the cache key
/// sensitive to on-disk changes so an edited file is re-read instead of
/// served stale. `None` preserves path-only caching for callers that do
/// not need invalidation.
content_version: Option<LocalFileContentVersion>,
},
/// Image loaded directly with bytes
Raw { id: String },
}
impl AssetSource {
/// Returns this source with a freshly-read local-file content version
/// attached when it is an [`AssetSource::LocalFile`]; all other variants are
/// returned unchanged.
///
/// Reads filesystem metadata, so call this off the render hot path (for
/// example, once when a view resolves its image sources), never per frame.
pub fn with_local_file_content_version(self) -> Self {
match self {
AssetSource::LocalFile { path, .. } => {
let content_version = LocalFileContentVersion::for_path(&path);
AssetSource::LocalFile {
path,
content_version,
}
}
other => other,
}
}
}
/// The public representation of an asset's current state (i.e., in-memory availability).
pub enum AssetState<T> {
Loading { handle: AssetHandle },
@@ -163,6 +231,7 @@ pub struct AssetCache {
inner: Rc<RefCell<HashMap<AssetHandle, AssetStateInternal>>>,
bundled_asset_provider: Box<dyn AssetProvider>,
image_cache: ImageCache,
foreground_executor: Rc<executor::Foreground>,
background_executor: Arc<executor::Background>,
}
@@ -192,15 +261,18 @@ impl Asset for String {
impl AssetCache {
const MAX_RAW_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
const MAX_VERSIONED_LOCAL_FILE_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
pub fn new(
bundled_asset_provider: Box<dyn AssetProvider>,
image_cache: ImageCache,
foreground_executor: Rc<executor::Foreground>,
background_executor: Arc<executor::Background>,
) -> Self {
Self {
inner: Rc::new(RefCell::new(HashMap::new())),
bundled_asset_provider,
image_cache,
foreground_executor,
background_executor,
}
@@ -275,6 +347,59 @@ impl AssetCache {
evicted_image_ids
}
fn evict_versioned_local_file_assets(
assets: &mut HashMap<AssetHandle, AssetStateInternal>,
max_total_size: usize,
) -> Vec<AssetSource> {
let mut versioned_local_file_assets: Vec<_> = assets
.iter()
.filter_map(|(handle, state)| {
if matches!(
handle.source,
AssetSource::LocalFile {
content_version: Some(_),
..
}
) {
if let AssetStateInternal::Loaded {
timestamp,
size_in_bytes,
..
} = state
{
return Some((handle.clone(), *timestamp, *size_in_bytes));
}
}
None
})
.collect();
let mut total_size = versioned_local_file_assets
.iter()
.map(|(_, _, size_in_bytes)| size_in_bytes)
.sum::<usize>();
if total_size <= max_total_size {
return vec![];
}
versioned_local_file_assets.sort_by_key(|(_, timestamp, _)| *timestamp);
let mut evicted_sources = vec![];
for (handle, _, size_in_bytes) in versioned_local_file_assets {
if total_size <= max_total_size {
break;
}
if assets.remove(&handle).is_some() {
total_size -= size_in_bytes;
evicted_sources.push(handle.source.clone());
assets.insert(handle, AssetStateInternal::Evicted);
}
}
evicted_sources
}
/// The main API of the asset cache. Given the location of an asset, returns an indicator of the
/// in-memory availability of the asset. If the asset is not already loaded or loading, a background
/// task is spawned to perform the retrieval.
@@ -317,7 +442,7 @@ impl AssetCache {
};
assets.insert(key.clone(), asset_state);
}
AssetSource::LocalFile { path } => {
AssetSource::LocalFile { path, .. } => {
assets.insert(key.clone(), AssetStateInternal::loading());
self.load_asynchronously::<T>(
source.clone(),
@@ -434,6 +559,7 @@ impl AssetCache {
// Spawn a receiver on the foreground executor.
let assets = Rc::downgrade(&self.inner);
let image_cache = self.image_cache.clone();
self.foreground_executor
.spawn_boxed(Box::pin(async move {
let result = match rx.await {
@@ -483,6 +609,15 @@ impl AssetCache {
assets.insert(handle, AssetStateInternal::Error(Rc::new(err)));
}
}
let evicted_sources = Self::evict_versioned_local_file_assets(
&mut assets,
Self::MAX_VERSIONED_LOCAL_FILE_ASSET_SIZE,
);
drop(assets);
for source in evicted_sources {
image_cache.evict_image(&source);
}
}))
.detach();
}
@@ -498,3 +633,7 @@ impl Entity for AssetCache {
}
impl SingletonEntity for AssetCache {}
#[cfg(test)]
#[path = "asset_cache_tests.rs"]
mod tests;
@@ -0,0 +1,167 @@
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::rc::Rc;
use std::time::{Duration, UNIX_EPOCH};
use super::{AssetCache, AssetHandle, AssetSource, AssetStateInternal, LocalFileContentVersion};
#[cfg(not(target_arch = "wasm32"))]
fn unique_temp_path(name: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"warp_asset_cache_test_{}_{name}",
std::process::id()
));
path
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn local_file_content_version_changes_when_file_contents_change() {
let path = unique_temp_path("contents_change.png");
std::fs::write(&path, b"aaaa").expect("write temp file");
let path_string = path.to_string_lossy().to_string();
let source = AssetSource::LocalFile {
path: path_string.clone(),
content_version: None,
}
.with_local_file_content_version();
match &source {
AssetSource::LocalFile {
path: resolved_path,
content_version,
} => {
assert_eq!(resolved_path, &path_string);
let content_version = content_version
.as_ref()
.expect("expected a content version for an existing file");
assert_eq!(
content_version.modified,
std::fs::metadata(&path)
.expect("read temp file metadata")
.modified()
.ok()
);
assert_eq!(content_version.file_size, 4);
}
other => panic!("expected a local file source, got {other:?}"),
}
let unchanged = AssetSource::LocalFile {
path: path_string.clone(),
content_version: None,
}
.with_local_file_content_version();
assert_eq!(
source, unchanged,
"an unmodified file should produce the same cache key"
);
std::fs::write(&path, b"bbbbbbbb").expect("rewrite temp file");
let after_change = AssetSource::LocalFile {
path: path_string,
content_version: None,
}
.with_local_file_content_version();
assert_ne!(
source, after_change,
"changing file contents should produce a different cache key"
);
let _ = std::fs::remove_file(&path);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn local_file_content_version_is_none_for_missing_file() {
let path = unique_temp_path("definitely_missing.png");
let _ = std::fs::remove_file(&path);
assert!(
LocalFileContentVersion::for_path(&path).is_none(),
"a missing file should not produce a content version"
);
}
#[test]
fn with_local_file_content_version_leaves_non_local_sources_unchanged() {
let bundled = AssetSource::Bundled { path: "icon.svg" };
assert_eq!(bundled.clone().with_local_file_content_version(), bundled);
}
#[test]
fn evicts_oldest_versioned_local_file_assets_over_limit() {
let oldest_source = AssetSource::LocalFile {
path: "oldest.png".to_string(),
content_version: Some(LocalFileContentVersion {
modified: Some(UNIX_EPOCH + Duration::from_secs(1)),
file_size: 4,
}),
};
let newest_source = AssetSource::LocalFile {
path: "newest.png".to_string(),
content_version: Some(LocalFileContentVersion {
modified: Some(UNIX_EPOCH + Duration::from_secs(2)),
file_size: 4,
}),
};
let unversioned_source = AssetSource::LocalFile {
path: "unversioned.png".to_string(),
content_version: None,
};
let oldest_handle = AssetHandle {
source: oldest_source.clone(),
asset_type: TypeId::of::<String>(),
};
let newest_handle = AssetHandle {
source: newest_source.clone(),
asset_type: TypeId::of::<String>(),
};
let unversioned_handle = AssetHandle {
source: unversioned_source,
asset_type: TypeId::of::<String>(),
};
let mut assets = HashMap::from([
(
oldest_handle.clone(),
AssetStateInternal::Loaded {
data: Rc::new(String::new()) as Rc<dyn Any>,
timestamp: 1,
size_in_bytes: 4,
},
),
(
newest_handle.clone(),
AssetStateInternal::Loaded {
data: Rc::new(String::new()) as Rc<dyn Any>,
timestamp: 2,
size_in_bytes: 4,
},
),
(
unversioned_handle.clone(),
AssetStateInternal::Loaded {
data: Rc::new(String::new()) as Rc<dyn Any>,
timestamp: 0,
size_in_bytes: 10,
},
),
]);
let evicted_sources = AssetCache::evict_versioned_local_file_assets(&mut assets, 4);
assert_eq!(evicted_sources, vec![oldest_source]);
assert!(matches!(
assets.get(&oldest_handle),
Some(AssetStateInternal::Evicted)
));
assert!(matches!(
assets.get(&newest_handle),
Some(AssetStateInternal::Loaded { .. })
));
assert!(matches!(
assets.get(&unversioned_handle),
Some(AssetStateInternal::Loaded { .. })
));
}
+2 -1
View File
@@ -1,5 +1,6 @@
use anyhow::{anyhow, Result};
use std::borrow::Cow;
use anyhow::{anyhow, Result};
pub mod asset_cache;
impl AssetProvider for () {