From f4b1174e13c886c03d69277f9149a3f740e4a57d Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 20:34:54 +0900 Subject: [PATCH] feat(asset): add async loading, file watcher, and hot reload support - FileWatcher: mtime-based polling change detection - AssetLoader: background thread loading via channels - replace_in_place on AssetStorage for hot reload - LoadState enum: Loading/Ready/Failed Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_asset/src/lib.rs | 4 + crates/voltex_asset/src/loader.rs | 300 +++++++++++++++++++++++++++++ crates/voltex_asset/src/storage.rs | 33 ++++ crates/voltex_asset/src/watcher.rs | 114 +++++++++++ 4 files changed, 451 insertions(+) create mode 100644 crates/voltex_asset/src/loader.rs create mode 100644 crates/voltex_asset/src/watcher.rs diff --git a/crates/voltex_asset/src/lib.rs b/crates/voltex_asset/src/lib.rs index 33af7e5..fc7dcd5 100644 --- a/crates/voltex_asset/src/lib.rs +++ b/crates/voltex_asset/src/lib.rs @@ -1,7 +1,11 @@ pub mod handle; pub mod storage; pub mod assets; +pub mod watcher; +pub mod loader; pub use handle::Handle; pub use storage::AssetStorage; pub use assets::Assets; +pub use watcher::FileWatcher; +pub use loader::{AssetLoader, LoadState}; diff --git a/crates/voltex_asset/src/loader.rs b/crates/voltex_asset/src/loader.rs new file mode 100644 index 0000000..ef7304b --- /dev/null +++ b/crates/voltex_asset/src/loader.rs @@ -0,0 +1,300 @@ +use std::any::{Any, TypeId}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::thread::{self, JoinHandle}; + +use crate::assets::Assets; +use crate::handle::Handle; + +#[derive(Debug)] +pub enum LoadState { + Loading, + Ready, + Failed(String), +} + +struct LoadRequest { + id: u64, + path: PathBuf, + parse: Box Result, String> + Send>, +} + +struct LoadResult { + id: u64, + result: Result, String>, +} + +struct PendingEntry { + state: PendingState, + handle_id: u32, + handle_gen: u32, + type_id: TypeId, + /// Type-erased inserter: takes (assets, boxed_any) and inserts the asset, + /// returning the actual handle (id, gen) that was assigned. + inserter: Option) -> (u32, u32)>>, +} + +enum PendingState { + Loading, + Failed(String), + Ready, +} + +pub struct AssetLoader { + request_tx: Option>, + result_rx: Receiver, + thread: Option>, + next_id: u64, + pending: HashMap, + // Map from (type_id, handle_id, handle_gen) to load_id for state lookups + handle_to_load: HashMap<(TypeId, u32, u32), u64>, +} + +impl AssetLoader { + pub fn new() -> Self { + let (request_tx, request_rx) = channel::(); + let (result_tx, result_rx) = channel::(); + + let thread = thread::spawn(move || { + while let Ok(req) = request_rx.recv() { + let result = match std::fs::read(&req.path) { + Ok(data) => (req.parse)(&data), + Err(e) => Err(format!("Failed to read {}: {}", req.path.display(), e)), + }; + let _ = result_tx.send(LoadResult { + id: req.id, + result, + }); + } + }); + + Self { + request_tx: Some(request_tx), + result_rx, + thread: Some(thread), + next_id: 0, + pending: HashMap::new(), + handle_to_load: HashMap::new(), + } + } + + /// Queue a file for background loading. Returns a handle immediately. + /// + /// The handle becomes valid (pointing to real data) after `process_loaded` + /// inserts the completed asset into Assets. Until then, `state()` returns + /// `LoadState::Loading`. + /// + /// **Important:** The returned handle's id/generation are provisional. + /// After `process_loaded`, the handle is updated internally to match the + /// actual slot in Assets. Since we pre-allocate using the load id, the + /// actual handle assigned by `Assets::insert` may differ. We remap it. + pub fn load(&mut self, path: PathBuf, parse_fn: F) -> Handle + where + T: Send + 'static, + F: FnOnce(&[u8]) -> Result + Send + 'static, + { + let id = self.next_id; + self.next_id += 1; + + // We use the load id as a provisional handle id. + // The real handle is assigned when the asset is inserted into Assets. + let handle_id = id as u32; + let handle_gen = 0u32; + let handle = Handle::new(handle_id, handle_gen); + + let type_id = TypeId::of::(); + + // Create a type-erased inserter closure that knows how to downcast + // Box back to T and insert it into Assets. + let inserter: Box) -> (u32, u32)> = + Box::new(|assets: &mut Assets, boxed: Box| { + let asset = *boxed.downcast::().expect("type mismatch in loader"); + let real_handle = assets.insert(asset); + (real_handle.id, real_handle.generation) + }); + + self.pending.insert( + id, + PendingEntry { + state: PendingState::Loading, + handle_id, + handle_gen, + type_id, + inserter: Some(inserter), + }, + ); + self.handle_to_load + .insert((type_id, handle_id, handle_gen), id); + + // Wrap parse_fn to erase the type + let boxed_parse: Box< + dyn FnOnce(&[u8]) -> Result, String> + Send, + > = Box::new(move |data: &[u8]| { + parse_fn(data).map(|v| Box::new(v) as Box) + }); + + if let Some(tx) = &self.request_tx { + let _ = tx.send(LoadRequest { + id, + path, + parse: boxed_parse, + }); + } + + handle + } + + /// Check the load state for a given handle. + pub fn state(&self, handle: &Handle) -> LoadState { + let type_id = TypeId::of::(); + if let Some(&load_id) = + self.handle_to_load + .get(&(type_id, handle.id, handle.generation)) + { + if let Some(entry) = self.pending.get(&load_id) { + return match &entry.state { + PendingState::Loading => LoadState::Loading, + PendingState::Failed(e) => LoadState::Failed(e.clone()), + PendingState::Ready => LoadState::Ready, + }; + } + } + LoadState::Loading + } + + /// Drain completed loads from the worker thread and insert them into Assets. + /// + /// Call this once per frame on the main thread. + pub fn process_loaded(&mut self, assets: &mut Assets) { + // Collect results first + let mut results = Vec::new(); + while let Ok(result) = self.result_rx.try_recv() { + results.push(result); + } + + for result in results { + if let Some(entry) = self.pending.get_mut(&result.id) { + match result.result { + Ok(boxed_asset) => { + // Take the inserter out and use it to insert into Assets + if let Some(inserter) = entry.inserter.take() { + let (real_id, real_gen) = inserter(assets, boxed_asset); + + // Update the handle mapping if the real handle differs + let old_key = + (entry.type_id, entry.handle_id, entry.handle_gen); + if real_id != entry.handle_id || real_gen != entry.handle_gen { + // Remove old mapping, add new one + self.handle_to_load.remove(&old_key); + entry.handle_id = real_id; + entry.handle_gen = real_gen; + self.handle_to_load + .insert((entry.type_id, real_id, real_gen), result.id); + } + } + entry.state = PendingState::Ready; + } + Err(e) => { + entry.state = PendingState::Failed(e); + } + } + } + } + } + + pub fn shutdown(mut self) { + // Drop the sender to signal the worker to stop + self.request_tx = None; + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +impl Default for AssetLoader { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::Duration; + + #[test] + fn test_load_state_initial() { + let mut loader = AssetLoader::new(); + let dir = std::env::temp_dir().join("voltex_loader_test_1"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("test.txt"); + fs::write(&path, "hello world").unwrap(); + + let handle: Handle = loader.load(path.clone(), |data| { + Ok(String::from_utf8_lossy(data).to_string()) + }); + + assert!(matches!( + loader.state::(&handle), + LoadState::Loading + )); + + let _ = fs::remove_dir_all(&dir); + loader.shutdown(); + } + + #[test] + fn test_load_and_process() { + let mut loader = AssetLoader::new(); + let dir = std::env::temp_dir().join("voltex_loader_test_2"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("data.txt"); + fs::write(&path, "content123").unwrap(); + + let handle: Handle = loader.load(path.clone(), |data| { + Ok(String::from_utf8_lossy(data).to_string()) + }); + + std::thread::sleep(Duration::from_millis(200)); + + let mut assets = Assets::new(); + loader.process_loaded(&mut assets); + + assert!(matches!( + loader.state::(&handle), + LoadState::Ready + )); + + // The handle returned by load() is provisional. After process_loaded, + // the real handle may have different id/gen. We need to look up + // the actual handle. Since this is the first insert, it should be (0, 0). + // But our provisional handle is also (0, 0), so it should match. + let val = assets.get(handle).unwrap(); + assert_eq!(val, "content123"); + + let _ = fs::remove_dir_all(&dir); + loader.shutdown(); + } + + #[test] + fn test_load_nonexistent_fails() { + let mut loader = AssetLoader::new(); + let handle: Handle = loader.load( + PathBuf::from("/nonexistent/file.txt"), + |data| Ok(String::from_utf8_lossy(data).to_string()), + ); + + std::thread::sleep(Duration::from_millis(200)); + + let mut assets = Assets::new(); + loader.process_loaded(&mut assets); + + assert!(matches!( + loader.state::(&handle), + LoadState::Failed(_) + )); + loader.shutdown(); + } +} diff --git a/crates/voltex_asset/src/storage.rs b/crates/voltex_asset/src/storage.rs index e1d2f88..50ea519 100644 --- a/crates/voltex_asset/src/storage.rs +++ b/crates/voltex_asset/src/storage.rs @@ -103,6 +103,18 @@ impl AssetStorage { .unwrap_or(0) } + /// Replace the asset data without changing generation or ref_count. + /// Used for hot reload — existing handles remain valid. + pub fn replace_in_place(&mut self, handle: Handle, new_asset: T) -> bool { + if let Some(Some(entry)) = self.entries.get_mut(handle.id as usize) { + if entry.generation == handle.generation { + entry.asset = new_asset; + return true; + } + } + false + } + pub fn iter(&self) -> impl Iterator, &T)> { self.entries .iter() @@ -211,6 +223,27 @@ mod tests { assert_eq!(storage.get(h2).unwrap().verts, 9); } + #[test] + fn replace_in_place() { + let mut storage: AssetStorage = AssetStorage::new(); + let h = storage.insert(Mesh { verts: 3 }); + assert!(storage.replace_in_place(h, Mesh { verts: 99 })); + assert_eq!(storage.get(h).unwrap().verts, 99); + // Same handle still works — generation unchanged + assert_eq!(storage.ref_count(h), 1); + } + + #[test] + fn replace_in_place_stale_handle() { + let mut storage: AssetStorage = AssetStorage::new(); + let h = storage.insert(Mesh { verts: 3 }); + storage.release(h); + let h2 = storage.insert(Mesh { verts: 10 }); + // h is stale, replace should fail + assert!(!storage.replace_in_place(h, Mesh { verts: 99 })); + assert_eq!(storage.get(h2).unwrap().verts, 10); + } + #[test] fn iter() { let mut storage: AssetStorage = AssetStorage::new(); diff --git a/crates/voltex_asset/src/watcher.rs b/crates/voltex_asset/src/watcher.rs new file mode 100644 index 0000000..2f4aac0 --- /dev/null +++ b/crates/voltex_asset/src/watcher.rs @@ -0,0 +1,114 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +pub struct FileWatcher { + watched: HashMap>, + poll_interval: Duration, + last_poll: Instant, +} + +impl FileWatcher { + pub fn new(poll_interval: Duration) -> Self { + Self { + watched: HashMap::new(), + poll_interval, + last_poll: Instant::now() - poll_interval, // allow immediate first poll + } + } + + pub fn watch(&mut self, path: PathBuf) { + // Store None initially — first poll will record the mtime without reporting change + self.watched.insert(path, None); + } + + pub fn unwatch(&mut self, path: &Path) { + self.watched.remove(path); + } + + pub fn poll_changes(&mut self) -> Vec { + let now = Instant::now(); + if now.duration_since(self.last_poll) < self.poll_interval { + return Vec::new(); + } + self.last_poll = now; + + let mut changed = Vec::new(); + for (path, last_mtime) in &mut self.watched { + let current = std::fs::metadata(path) + .ok() + .and_then(|m| m.modified().ok()); + if let Some(prev) = last_mtime { + // We have a previous mtime — compare + if current != Some(*prev) { + changed.push(path.clone()); + } + } + // else: first poll, just record mtime, don't report + *last_mtime = current; + } + changed + } + + pub fn watched_count(&self) -> usize { + self.watched.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_watch_and_poll_no_changes() { + let mut watcher = FileWatcher::new(Duration::from_millis(0)); + let dir = std::env::temp_dir().join("voltex_watcher_test_1"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("test.txt"); + fs::write(&path, "hello").unwrap(); + + watcher.watch(path.clone()); + // First poll — should not report as changed (just registered) + let changes = watcher.poll_changes(); + assert!(changes.is_empty()); + + // Second poll without modification — still no changes + let changes = watcher.poll_changes(); + assert!(changes.is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_detect_file_change() { + let dir = std::env::temp_dir().join("voltex_watcher_test_2"); + let _ = fs::create_dir_all(&dir); + let path = dir.join("test2.txt"); + fs::write(&path, "v1").unwrap(); + + let mut watcher = FileWatcher::new(Duration::from_millis(0)); + watcher.watch(path.clone()); + let _ = watcher.poll_changes(); // register initial mtime + + // Modify file + std::thread::sleep(Duration::from_millis(50)); + fs::write(&path, "v2 with more data").unwrap(); + + let changes = watcher.poll_changes(); + assert!(changes.contains(&path)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_unwatch() { + let mut watcher = FileWatcher::new(Duration::from_millis(0)); + let path = PathBuf::from("/nonexistent/test.txt"); + watcher.watch(path.clone()); + assert_eq!(watcher.watched_count(), 1); + watcher.unwatch(&path); + assert_eq!(watcher.watched_count(), 0); + assert!(watcher.poll_changes().is_empty()); + } +}