Skip to main content

motion_core/
asset.rs

1//! Lazy typed assets with explicit bounded project pools and request diagnostics.
2//!
3//! ```
4//! use motion_core::{Asset, Assets, AssetStatus, LoadContext, Source};
5//! use std::sync::Arc;
6//! struct Label(String);
7//! impl Asset for Label {
8//!     const KIND: &'static str = "label";
9//!     fn decode(bytes: &[u8], _: &LoadContext) -> motion_core::Result<Self> {
10//!         Ok(Self(std::str::from_utf8(bytes)?.to_owned()))
11//!     }
12//!     fn retained_bytes(&self) -> usize {
13//!         self.0.len()
14//!     }
15//! }
16//! let assets = Assets::new("assets")?;
17//! let handle = assets.load::<Label>(Source::Bytes {
18//!     key: "title".into(),
19//!     data: Arc::from(&b"Native resources"[..]),
20//! });
21//! assert_eq!(handle.status(), AssetStatus::Unloaded);
22//! assert_eq!(handle.get()?.0, "Native resources");
23//! # Ok::<(), motion_core::Error>(())
24//! ```
25
26use std::{
27    any::{Any, TypeId},
28    collections::BTreeMap,
29    io::{self, Read},
30    path::{Path, PathBuf},
31    sync::{
32        Arc, Mutex, OnceLock, Weak,
33        atomic::{AtomicBool, AtomicUsize, Ordering},
34    },
35};
36
37use handle::Entry;
38
39pub mod environment;
40mod handle;
41mod pool;
42#[cfg(test)]
43mod tests;
44
45pub use environment::{FontEnvironment, FontIdentity};
46pub use handle::{AssetStatus, Handle};
47pub use pool::ResourcePool;
48
49const MAX_ENCODED: usize = 64 * 1024 * 1024;
50
51const MAX_CACHE_BYTES: usize = 256 * 1024 * 1024;
52
53const MAX_ENTRIES: usize = 4096;
54
55thread_local! {
56    static RESOLVING: std::cell::RefCell<Vec<usize>> = const { std::cell::RefCell::new(Vec::new()) };
57}
58
59/// Explicit request-owned diagnostic sink, safely shared with background tasks.
60/// Clones append to the same sink. Assets views and handles carry it explicitly.
61#[derive(Clone, Default, Debug)]
62pub struct RequestDiagnostics(Arc<Mutex<Vec<Arc<crate::Error>>>>);
63
64impl RequestDiagnostics {
65    /// Captures a failure once by Arc identity, not by its text or semantic equality.
66    pub fn record(&self, error: Arc<crate::Error>) {
67        let mut errors = self
68            .0
69            .lock()
70            .unwrap_or_else(std::sync::PoisonError::into_inner);
71        if !errors.iter().any(|existing| Arc::ptr_eq(existing, &error)) {
72            errors.push(error);
73        }
74    }
75
76    /// Snapshot of failures belonging to this request only.
77    pub fn errors(&self) -> Vec<Arc<crate::Error>> {
78        self.0
79            .lock()
80            .unwrap_or_else(std::sync::PoisonError::into_inner)
81            .clone()
82    }
83}
84
85struct ResolveGuard;
86
87impl Drop for ResolveGuard {
88    fn drop(&mut self) {
89        RESOLVING.with(|entries| {
90            entries.borrow_mut().pop();
91        });
92    }
93}
94
95/// A custom typed resource decoded once for a shared source and project context.
96pub trait Asset: Sized + Send + Sync + 'static {
97    /// Human-readable resource category retained by load diagnostics.
98    const KIND: &'static str;
99
100    /// Decodes bounded input without requiring a rendering device.
101    /// Implementations must be deterministic for these bytes/context; recursive
102    /// resource dependency graphs are not a supported decoder contract.
103    fn decode(bytes: &[u8], context: &LoadContext) -> crate::Result<Self>;
104
105    /// Logical decoded payload charge; custom heap-owning assets should override.
106    /// At least the encoded input length is charged regardless of this value.
107    fn retained_bytes(&self) -> usize {
108        std::mem::size_of::<Self>()
109    }
110}
111
112/// Local file or immutable named bytes. Byte identity includes the content,
113/// so reusing a name with different bytes cannot return the wrong cached asset.
114#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
115pub enum Source {
116    /// Relative to the Assets root, or an explicitly supplied absolute path.
117    File(PathBuf),
118    /// Embedded bytes with a diagnostic identifier.
119    Bytes {
120        /// Stable user-facing resource name.
121        key: Arc<str>,
122        /// Immutable encoded content.
123        data: Arc<[u8]>,
124    },
125}
126
127impl std::fmt::Debug for Source {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::File(path) => formatter.debug_tuple("File").field(path).finish(),
131            Self::Bytes { key, data } => formatter
132                .debug_struct("Bytes")
133                .field("key", key)
134                .field("length", &data.len())
135                .finish(),
136        }
137    }
138}
139
140impl From<PathBuf> for Source {
141    fn from(path: PathBuf) -> Self {
142        Self::File(path)
143    }
144}
145
146impl From<&Path> for Source {
147    fn from(path: &Path) -> Self {
148        Self::File(path.to_path_buf())
149    }
150}
151
152impl From<&str> for Source {
153    fn from(path: &str) -> Self {
154        Self::File(path.into())
155    }
156}
157
158impl From<String> for Source {
159    fn from(path: String) -> Self {
160        Self::File(path.into())
161    }
162}
163
164/// Immutable loader configuration; no font discovery, GPU or encoder is created.
165#[derive(Clone, Debug)]
166pub struct LoadContext {
167    root: Arc<PathBuf>,
168    fonts: Option<crate::asset::environment::FontEnvironment>,
169    namespace: u64,
170}
171
172impl LoadContext {
173    /// Immutable font environment supplied by this resource owner, if any.
174    pub fn fonts(&self) -> Option<&crate::asset::environment::FontEnvironment> {
175        self.fonts.as_ref()
176    }
177
178    /// Absolute project asset directory captured when Assets is created.
179    pub fn root(&self) -> &Path {
180        &self.root
181    }
182
183    /// Maximum encoded bytes accepted by a single resource.
184    pub fn max_encoded_bytes(&self) -> usize {
185        MAX_ENCODED
186    }
187}
188
189type CacheKey = (TypeId, PathBuf, u64, u64, Source);
190
191type Erased = dyn Any + Send + Sync;
192
193struct OwnedEntry {
194    value: Arc<Erased>,
195    initialized: Box<dyn Fn() -> bool + Send + Sync>,
196}
197
198impl OwnedEntry {
199    fn new<AssetValue: Asset>(entry: Arc<Entry<AssetValue>>) -> Arc<Self> {
200        let weak = Arc::downgrade(&entry);
201        Arc::new(Self {
202            value: entry,
203            initialized: Box::new(move || {
204                weak.upgrade()
205                    .is_some_and(|entry| entry.result.get().is_some())
206            }),
207        })
208    }
209}
210
211/// Bounded project resource owner. Owners/handles retain project weak cache
212/// entries; dropping them releases cache payload charges. This is not a hard
213/// process-memory cap or an automatic file-change watcher.
214#[derive(Clone)]
215pub struct Assets {
216    context: LoadContext,
217    entries: Arc<Mutex<BTreeMap<CacheKey, Arc<OwnedEntry>>>>,
218    pool: ResourcePool,
219    diagnostics: Option<RequestDiagnostics>,
220}
221
222impl std::fmt::Debug for Assets {
223    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        formatter
225            .debug_struct("Assets")
226            .field("root", &self.context.root)
227            .finish_non_exhaustive()
228    }
229}
230
231impl Default for Assets {
232    fn default() -> Self {
233        Self {
234            context: LoadContext {
235                root: Arc::new(PathBuf::new()),
236                fonts: None,
237                namespace: 0,
238            },
239            entries: Arc::new(Mutex::new(BTreeMap::new())),
240            pool: ResourcePool::new(),
241            diagnostics: None,
242        }
243    }
244}
245
246impl Assets {
247    /// Derives a request-local view without changing resource identity or ownership.
248    /// Handles created from this view keep the explicit sink across threads.
249    pub fn with_diagnostics(mut self, diagnostics: RequestDiagnostics) -> Self {
250        self.diagnostics = Some(diagnostics);
251        self
252    }
253
254    /// Freezes existing entries while isolating future registrations from old clones.
255    /// Snapshot versions still share project accounting and existing decoded payloads.
256    pub fn snapshot(&self) -> Self {
257        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
258        let mut snapshot = self.clone();
259        snapshot.context.namespace = NEXT.fetch_add(1, Ordering::Relaxed);
260        let entries = self
261            .entries
262            .lock()
263            .unwrap_or_else(std::sync::PoisonError::into_inner)
264            .iter()
265            .filter(|(_, entry)| (entry.initialized)())
266            .map(|(key, entry)| {
267                let mut key = key.clone();
268                key.3 = snapshot.context.namespace;
269                (key, entry.clone())
270            })
271            .collect();
272        snapshot.entries = Arc::new(Mutex::new(entries));
273        snapshot
274    }
275
276    /// Registers decoded data in the same bounded entry used by lazy handles.
277    /// Performs no file read; an already resolved source is never replaced.
278    pub fn preload<A: Asset>(
279        &self,
280        source: impl Into<Source>,
281        value: Arc<A>,
282    ) -> crate::Result<Handle<A>> {
283        let handle = self.load::<A>(source);
284        let inserted = std::cell::Cell::new(false);
285        let result = handle.entry.result.get_or_init(|| {
286            inserted.set(true);
287            let charge = value
288                .retained_bytes()
289                .checked_add(handle.entry.charged.load(Ordering::Acquire));
290            let result = charge
291                .ok_or_else(|| {
292                    io::Error::new(
293                        io::ErrorKind::OutOfMemory,
294                        "Asset payload accounting overflow",
295                    )
296                })
297                .and_then(|charge| {
298                    let additional = charge - handle.entry.charged.load(Ordering::Acquire);
299                    self.pool
300                        .0
301                        .bytes
302                        .try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
303                            current
304                                .checked_add(additional)
305                                .filter(|total| *total <= self.pool.0.max_bytes)
306                        })
307                        .map_err(|_| {
308                            io::Error::new(
309                                io::ErrorKind::OutOfMemory,
310                                "Shared asset-cache logical payload exceeds its allowance",
311                            )
312                        })?;
313                    handle.entry.charged.store(charge, Ordering::Release);
314                    Ok(value)
315                });
316            result.map_err(|error| {
317                Arc::new(crate::Error::asset(A::KIND, handle.source_name(), error))
318            })
319        });
320        if let Err(error) = result {
321            return Err(crate::Error::asset(
322                A::KIND,
323                handle.source_name(),
324                error.clone(),
325            ));
326        }
327        if !inserted.get() {
328            return Err(io::Error::new(
329                io::ErrorKind::AlreadyExists,
330                "Asset source is already resolved",
331            )
332            .into());
333        }
334        Ok(handle)
335    }
336
337    /// Uses an explicit project pool and drops this value's retained entry table.
338    /// Existing owner clones, handles and decoded values keep their original ownership.
339    pub fn with_pool(mut self, pool: ResourcePool) -> Self {
340        self.pool = pool;
341        self.entries = Arc::new(Mutex::new(BTreeMap::new()));
342        self
343    }
344
345    /// Project cache identity and accounting shared by this owner's clones.
346    pub fn pool(&self) -> &ResourcePool {
347        &self.pool
348    }
349
350    /// Unregisters one typed source from this owner without invalidating handles.
351    /// Clones share registrations; use snapshot() before independent edits.
352    /// Retained snapshots and handles keep their payload charged until dropped.
353    pub fn remove<A: Asset>(&self, source: impl Into<Source>) -> bool {
354        let mut source = source.into();
355        if let Source::File(path) = &mut source
356            && !path.is_absolute()
357        {
358            *path = self.context.root.join(&*path);
359        }
360        let key = (
361            TypeId::of::<A>(),
362            self.context.root.as_ref().clone(),
363            self.context
364                .fonts
365                .as_ref()
366                .map_or(0, crate::asset::environment::FontEnvironment::snapshot_id),
367            self.context.namespace,
368            source,
369        );
370        let mut entries = self
371            .entries
372            .lock()
373            .unwrap_or_else(std::sync::PoisonError::into_inner);
374        let Some(entry) = entries.remove(&key) else {
375            return false;
376        };
377        let mut global = self
378            .pool
379            .0
380            .entries
381            .lock()
382            .unwrap_or_else(std::sync::PoisonError::into_inner);
383        if global
384            .get(&key)
385            .and_then(Weak::upgrade)
386            .is_some_and(|value| Arc::ptr_eq(&value, &entry.value))
387        {
388            global.remove(&key);
389        }
390        true
391    }
392
393    /// Derives an independent resource view with an immutable font environment.
394    /// Cache identity includes its snapshot; existing owners/handles stay valid.
395    pub fn with_fonts(
396        mut self,
397        fonts: impl Into<crate::asset::environment::FontEnvironment>,
398    ) -> Self {
399        self.context.fonts = Some(fonts.into());
400        self.entries = Arc::new(Mutex::new(BTreeMap::new()));
401        self
402    }
403
404    /// Font snapshot shared by layout and explicit SVG parsing.
405    pub fn fonts(&self) -> Option<&crate::asset::environment::FontEnvironment> {
406        self.context.fonts()
407    }
408
409    /// Captures a project asset directory without reading/decode/device work.
410    pub fn new(root: impl AsRef<Path>) -> crate::Result<Self> {
411        Ok(Self {
412            context: LoadContext {
413                root: Arc::new(std::path::absolute(root)?),
414                fonts: None,
415                namespace: 0,
416            },
417            entries: Arc::new(Mutex::new(BTreeMap::new())),
418            pool: ResourcePool::new(),
419            diagnostics: None,
420        })
421    }
422
423    /// Requests a lazy typed handle. Matching type/root/font snapshot/source shares
424    /// one entry only among owners attached to the same ResourcePool. Creating a
425    /// handle performs no file decode. Relative parent traversal is rejected.
426    pub fn load<A: Asset>(&self, source: impl Into<Source>) -> Handle<A> {
427        let mut source = source.into();
428        let failure = if let Source::File(path) = &mut source {
429            if self.context.root.as_os_str().is_empty() && !path.is_absolute() {
430                Some("Relative file assets require an explicit project root")
431            } else if path
432                .components()
433                .any(|component| component == std::path::Component::ParentDir)
434            {
435                Some("Asset paths must not contain parent traversal")
436            } else {
437                if !path.is_absolute() {
438                    *path = self.context.root.join(&*path);
439                }
440                None
441            }
442        } else if let Source::Bytes { key, data } = &source {
443            if data.len() > MAX_ENCODED {
444                Some("Asset source exceeds 64 MiB")
445            } else if key.is_empty() || key.len() > 1024 || key.chars().any(char::is_control) {
446                Some("Asset byte keys require 1..=1024 bytes without control characters")
447            } else {
448                None
449            }
450        } else {
451            None
452        };
453        let key = (
454            TypeId::of::<A>(),
455            self.context.root.as_ref().clone(),
456            self.context
457                .fonts
458                .as_ref()
459                .map_or(0, crate::asset::environment::FontEnvironment::snapshot_id),
460            self.context.namespace,
461            source.clone(),
462        );
463        let mut entries = self
464            .entries
465            .lock()
466            .unwrap_or_else(std::sync::PoisonError::into_inner);
467        if failure.is_none()
468            && let Some(entry) = entries.get(&key)
469        {
470            return Handle {
471                entry: entry
472                    .value
473                    .clone()
474                    .downcast::<Entry<A>>()
475                    .expect("typed resource key"),
476                diagnostics: self.diagnostics.clone(),
477            };
478        }
479        let mut global = self
480            .pool
481            .0
482            .entries
483            .lock()
484            .unwrap_or_else(std::sync::PoisonError::into_inner);
485        global.retain(|_, entry| entry.strong_count() > 0);
486        let failure = failure.or_else(|| {
487            (entries.len() >= self.pool.0.max_entries)
488                .then_some("Resource cache exceeds its entry allowance")
489        });
490        if failure.is_none()
491            && let Some(entry) = global.get(&key).and_then(Weak::upgrade)
492        {
493            let entry = entry.downcast::<Entry<A>>().expect("typed global key");
494            entries.insert(key, OwnedEntry::new(entry.clone()));
495            return Handle {
496                entry,
497                diagnostics: self.diagnostics.clone(),
498            };
499        }
500        let mut failure = failure.or_else(|| {
501            (global.len() >= self.pool.0.max_entries)
502                .then_some("Resource cache exceeds its entry allowance")
503        });
504        let mut charged = 0;
505        if failure.is_none()
506            && let Source::Bytes { data, .. } = &source
507        {
508            if self
509                .pool
510                .0
511                .bytes
512                .try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
513                    current
514                        .checked_add(data.len())
515                        .filter(|total| *total <= self.pool.0.max_bytes)
516                })
517                .is_ok()
518            {
519                charged = data.len();
520            } else {
521                failure = Some("Shared asset-cache logical payload exceeds its allowance");
522            }
523        }
524        if failure.is_some()
525            && let Source::Bytes { data, .. } = &mut source
526        {
527            *data = Arc::from([]);
528        }
529        let entry = Arc::new(Entry {
530            pool: self.pool.clone(),
531            source,
532            context: self.context.clone(),
533            result: OnceLock::new(),
534            loading: AtomicBool::new(false),
535            charged: AtomicUsize::new(charged),
536        });
537        if let Some(message) = failure {
538            let source_name = match &entry.source {
539                Source::File(path) => path.display().to_string(),
540                Source::Bytes { key, .. } => key.to_string(),
541            };
542            let _result = entry.result.set(Err(Arc::new(crate::Error::asset(
543                A::KIND,
544                source_name,
545                io::Error::new(io::ErrorKind::InvalidInput, message),
546            ))));
547        } else {
548            let erased: Arc<Erased> = entry.clone();
549            global.insert(key.clone(), Arc::downgrade(&erased));
550            entries.insert(key, OwnedEntry::new(entry.clone()));
551        }
552        Handle {
553            entry,
554            diagnostics: self.diagnostics.clone(),
555        }
556    }
557}