diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index caba32e..3cec8c5 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -175,6 +175,7 @@ is committed and listed here with its exports. | packaging/env.sh, packaging/.shellcheckrc, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 | | packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 | | packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 | +| crates/io/src/memory.rs (files kept in memory for the browser build) | SolidCraft `crates/io/src/vfs.rs` | MIT OR Apache-2.0 | | .github/workflows/release.yml, .github/release-notes.md, docs/releasing.md | SolidCraft `.github/workflows/release.yml`, `.github/release-notes.md`, `docs/releasing.md`; VectorCraft `.github/workflows/release.yml` | MIT OR Apache-2.0 | | .github/workflows/packaging-lint.yml | SolidCraft `.github/workflows/packaging-lint.yml` (itself adapted from PhotoCraft, https://github.com/storytold/photocraft, Apache-2.0) | MIT OR Apache-2.0 | | .github/workflows/arch-check.yml (the riscv64 cross build) | SolidCraft `.github/workflows/ci.yml` (`riscv64-gates`) | MIT OR Apache-2.0 | diff --git a/crates/io/src/fs.rs b/crates/io/src/fs.rs index 6209ba7..a2eec12 100644 --- a/crates/io/src/fs.rs +++ b/crates/io/src/fs.rs @@ -7,17 +7,18 @@ //! //! Adapted from SolidCraft's `crates/io/src/safe_write.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. +//! +//! In the browser (`wasm32`) the same calls read and write files kept in memory +//! ([`crate::memory`]): the web host puts files in ([`insert`]) and takes out what commands wrote +//! ([`take_writes`]). Everything else in FieldCraft reaches files only through this module. + +#[cfg(target_arch = "wasm32")] +pub use browser::*; +#[cfg(not(target_arch = "wasm32"))] +pub use disk::*; -use std::fs::{self, OpenOptions}; -use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use crate::{Error, Result}; - -fn io_error(path: &Path, e: std::io::Error) -> Error { - Error::Io(format!("{}: {e}", path.display())) -} - /// The backup of a file written with `backup: true`. pub fn backup_path(path: &Path) -> PathBuf { let mut s = path.as_os_str().to_owned(); @@ -25,185 +26,276 @@ pub fn backup_path(path: &Path) -> PathBuf { PathBuf::from(s) } -fn temp_beside(path: &Path, tag: &str) -> PathBuf { - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into()); - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id())) -} +#[cfg(target_arch = "wasm32")] +mod browser { + use std::cell::RefCell; + use std::path::{Path, PathBuf}; -fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - let mut f = OpenOptions::new().write(true).create_new(true).open(path)?; - f.write_all(bytes)?; - f.sync_all() -} + use crate::Result; + use crate::memory::MemoryFs; -/// Flush the directory entry (the rename) to disk where the platform allows it. -fn sync_dir(path: &Path) { - #[cfg(unix)] - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) - && let Ok(d) = fs::File::open(dir) - { - let _ = d.sync_all(); + thread_local! { + // The browser runs the app on one thread. + static FILES: RefCell = RefCell::new(MemoryFs::default()); + } + + /// Write `bytes` to `path` (a backup is not kept: the host hands each save out as a + /// download). + pub fn write_atomic(path: &Path, bytes: &[u8], _backup: bool) -> Result<()> { + FILES.with(|f| f.borrow_mut().write(path, bytes)) + } + + /// Read a file of at most `max` bytes. + pub fn read_capped(path: &Path, max: u64) -> Result> { + FILES.with(|f| f.borrow().read(path, max)) + } + + pub fn is_file(path: &Path) -> bool { + FILES.with(|f| f.borrow().is_file(path)) + } + + pub fn remove(path: &Path) -> Result<()> { + FILES.with(|f| f.borrow_mut().remove(path)) + } + + /// The files directly in `dir`. + pub fn list(dir: &Path) -> Vec { + FILES.with(|f| f.borrow().list(dir)) + } + + /// Folders are implied by the files' paths. + pub fn create_dir_all(_dir: &Path) -> Result<()> { + Ok(()) + } + + /// The host puts a file in (one picked, dropped or kept in the browser's storage). + pub fn insert(path: &Path, bytes: Vec) -> Result<()> { + FILES.with(|f| f.borrow_mut().insert(path, bytes)) + } + + /// What commands wrote since the last call, for the host to hand out or store. + pub fn take_writes() -> Vec<(PathBuf, std::sync::Arc<[u8]>)> { + FILES.with(|f| f.borrow_mut().take_writes()) } - #[cfg(not(unix))] - let _ = path; } -/// Steps of a save, so tests can stop one part way (as a crash would). -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] -enum Step { - TempWritten, - BackupWritten, - Replaced, -} +#[cfg(not(target_arch = "wasm32"))] +mod disk { + use std::fs::{self, OpenOptions}; + use std::io::{Read, Write}; + use std::path::{Path, PathBuf}; -fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> { - let tmp = temp_beside(path, "new"); - if let Err(e) = write_synced(&tmp, bytes) { - let _ = fs::remove_file(&tmp); - return Err(e); + use super::backup_path; + use crate::{Error, Result}; + + fn io_error(path: &Path, e: std::io::Error) -> Error { + Error::Io(format!("{}: {e}", path.display())) } - if stop == Step::TempWritten { - return Ok(()); + + fn temp_beside(path: &Path, tag: &str) -> PathBuf { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into()); + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id())) } - if backup && path.is_file() { - // Copy (not move) the old file, so the target exists at every moment. - let bak_tmp = temp_beside(path, "bak"); - let r = fs::copy(path, &bak_tmp) - .and_then(|_| OpenOptions::new().write(true).open(&bak_tmp)) - .and_then(|f| f.sync_all()) - .and_then(|_| fs::rename(&bak_tmp, backup_path(path))); - if let Err(e) = r { - let _ = fs::remove_file(&bak_tmp); + + fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut f = OpenOptions::new().write(true).create_new(true).open(path)?; + f.write_all(bytes)?; + f.sync_all() + } + + /// Flush the directory entry (the rename) to disk where the platform allows it. + fn sync_dir(path: &Path) { + #[cfg(unix)] + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) + && let Ok(d) = fs::File::open(dir) + { + let _ = d.sync_all(); + } + #[cfg(not(unix))] + let _ = path; + } + + /// Steps of a save, so tests can stop one part way (as a crash would). + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] + enum Step { + TempWritten, + BackupWritten, + Replaced, + } + + fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> { + let tmp = temp_beside(path, "new"); + if let Err(e) = write_synced(&tmp, bytes) { let _ = fs::remove_file(&tmp); return Err(e); } - } - if stop == Step::BackupWritten { - return Ok(()); - } - if let Err(e) = fs::rename(&tmp, path) { - let _ = fs::remove_file(&tmp); - return Err(e); - } - sync_dir(path); - // Temporary files of saves that crashed part way (a minute old or more) go. - for t in stale_temps(path) { - if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) { - let _ = fs::remove_file(t); + if stop == Step::TempWritten { + return Ok(()); } - } - Ok(()) -} - -/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as -/// `.bak` first. -pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> { - write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e)) -} - -/// Leftover temporary files of saves that never finished (a crash part way), next to `path`. -pub fn stale_temps(path: &Path) -> Vec { - let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() }; - let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let prefix = format!(".{name}."); - fs::read_dir(dir) - .into_iter() - .flatten() - .flatten() - .map(|e| e.path()) - .filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp"))) - .take(10_000) - .collect() -} - -/// Read a regular file of at most `max` bytes. -pub fn read_capped(path: &Path, max: u64) -> Result> { - let meta = fs::metadata(path).map_err(|e| io_error(path, e))?; - if !meta.is_file() { - return Err(Error::Io(format!("{}: not a regular file", path.display()))); - } - if meta.len() > max { - return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20))); - } - let f = fs::File::open(path).map_err(|e| io_error(path, e))?; - let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); - // The file may grow between the size check and the read. - f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?; - if bytes.len() as u64 > max { - return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20))); - } - Ok(bytes) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn dir(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id())); - let _ = fs::remove_dir_all(&d); - fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn replaces_and_keeps_a_backup() { - let d = dir("bak"); - let p = d.join("a.fieldcraft"); - write_atomic(&p, b"one", true).unwrap(); - assert!(!backup_path(&p).exists(), "nothing to back up the first time"); - write_atomic(&p, b"two", true).unwrap(); - assert_eq!(fs::read(&p).unwrap(), b"two"); - assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); - write_atomic(&p, b"three", false).unwrap(); - assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); - assert!(stale_temps(&p).is_empty()); - let _ = fs::remove_dir_all(&d); - } - - /// A save stopped after any step leaves the old file whole (or the new one). - #[test] - fn a_save_stopped_part_way_keeps_the_old_file() { - let d = dir("stop"); - let p = d.join("a.fieldcraft"); - write_atomic(&p, b"old", true).unwrap(); - for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] { - write_steps(&p, b"new content", true, stop).unwrap(); - let now = fs::read(&p).unwrap(); - if stop < Step::Replaced { - assert_eq!(now, b"old", "{stop:?}"); - assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over"); - for t in stale_temps(&p) { - fs::remove_file(t).unwrap(); - } - } else { - assert_eq!(now, b"new content"); + if backup && path.is_file() { + // Copy (not move) the old file, so the target exists at every moment. + let bak_tmp = temp_beside(path, "bak"); + let r = fs::copy(path, &bak_tmp) + .and_then(|_| OpenOptions::new().write(true).open(&bak_tmp)) + .and_then(|f| f.sync_all()) + .and_then(|_| fs::rename(&bak_tmp, backup_path(path))); + if let Err(e) = r { + let _ = fs::remove_file(&bak_tmp); + let _ = fs::remove_file(&tmp); + return Err(e); } - write_atomic(&p, b"old", false).unwrap(); } - let _ = fs::remove_dir_all(&d); + if stop == Step::BackupWritten { + return Ok(()); + } + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + sync_dir(path); + // Temporary files of saves that crashed part way (a minute old or more) go. + for t in stale_temps(path) { + if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) { + let _ = fs::remove_file(t); + } + } + Ok(()) } - #[test] - fn a_failed_write_leaves_the_target() { - let d = dir("fail"); - let p = d.join("missing-dir").join("a.fieldcraft"); - assert!(write_atomic(&p, b"x", true).is_err()); - assert!(!p.exists()); - let _ = fs::remove_dir_all(&d); + /// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as + /// `.bak` first. + pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> { + write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e)) } - #[test] - fn reads_are_capped() { - let d = dir("read"); - let p = d.join("a.bin"); - fs::write(&p, [7u8; 100]).unwrap(); - assert_eq!(read_capped(&p, 100).unwrap().len(), 100); - assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_)))); - assert!(read_capped(&d, 1 << 20).is_err()); - assert!(read_capped(&d.join("missing"), 1 << 20).is_err()); - let _ = fs::remove_dir_all(&d); + /// Leftover temporary files of saves that never finished (a crash part way), next to `path`. + pub fn stale_temps(path: &Path) -> Vec { + let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() }; + let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let prefix = format!(".{name}."); + fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp"))) + .take(10_000) + .collect() + } + + /// Read a regular file of at most `max` bytes. + pub fn read_capped(path: &Path, max: u64) -> Result> { + let meta = fs::metadata(path).map_err(|e| io_error(path, e))?; + if !meta.is_file() { + return Err(Error::Io(format!("{}: not a regular file", path.display()))); + } + if meta.len() > max { + return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20))); + } + let f = fs::File::open(path).map_err(|e| io_error(path, e))?; + let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); + // The file may grow between the size check and the read. + f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?; + if bytes.len() as u64 > max { + return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20))); + } + Ok(bytes) + } + + pub fn is_file(path: &Path) -> bool { + path.is_file() + } + + pub fn remove(path: &Path) -> Result<()> { + fs::remove_file(path).map_err(|e| io_error(path, e)) + } + + /// The files directly in `dir` (at most a million). + pub fn list(dir: &Path) -> Vec { + fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .take(1_000_000) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file())) + .map(|e| e.path()) + .collect() + } + + pub fn create_dir_all(dir: &Path) -> Result<()> { + fs::create_dir_all(dir).map_err(|e| io_error(dir, e)) + } + + #[cfg(test)] + mod tests { + use super::*; + + fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("fieldcraft-fs-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn replaces_and_keeps_a_backup() { + let d = dir("bak"); + let p = d.join("a.fieldcraft"); + write_atomic(&p, b"one", true).unwrap(); + assert!(!backup_path(&p).exists(), "nothing to back up the first time"); + write_atomic(&p, b"two", true).unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"two"); + assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); + write_atomic(&p, b"three", false).unwrap(); + assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); + assert!(stale_temps(&p).is_empty()); + let _ = fs::remove_dir_all(&d); + } + + /// A save stopped after any step leaves the old file whole (or the new one). + #[test] + fn a_save_stopped_part_way_keeps_the_old_file() { + let d = dir("stop"); + let p = d.join("a.fieldcraft"); + write_atomic(&p, b"old", true).unwrap(); + for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] { + write_steps(&p, b"new content", true, stop).unwrap(); + let now = fs::read(&p).unwrap(); + if stop < Step::Replaced { + assert_eq!(now, b"old", "{stop:?}"); + assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over"); + for t in stale_temps(&p) { + fs::remove_file(t).unwrap(); + } + } else { + assert_eq!(now, b"new content"); + } + write_atomic(&p, b"old", false).unwrap(); + } + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_target() { + let d = dir("fail"); + let p = d.join("missing-dir").join("a.fieldcraft"); + assert!(write_atomic(&p, b"x", true).is_err()); + assert!(!p.exists()); + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn reads_are_capped() { + let d = dir("read"); + let p = d.join("a.bin"); + fs::write(&p, [7u8; 100]).unwrap(); + assert_eq!(read_capped(&p, 100).unwrap().len(), 100); + assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_)))); + assert!(read_capped(&d, 1 << 20).is_err()); + assert!(read_capped(&d.join("missing"), 1 << 20).is_err()); + let _ = fs::remove_dir_all(&d); + } } } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 3bf6c28..03063f6 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -18,11 +18,12 @@ //! capped before anything is allocated, every part's checksum is verified, and the model is //! rebuilt through the same checked methods commands use, so a file cannot hold what commands //! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`], -//! [`recovery`] and [`roots`] touch the file system. +//! [`recovery`] and [`roots`] touch the file system, which in the browser is [`memory`]. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod fs; +pub mod memory; pub mod mesh; pub mod recovery; pub mod roots; diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs new file mode 100644 index 0000000..72aa0c6 --- /dev/null +++ b/crates/io/src/memory.rs @@ -0,0 +1,151 @@ +//! Files kept in memory: the file system of the browser build. +//! +//! A browser page has no file system it can call synchronously, so on `wasm32` [`crate::fs`] +//! keeps files here. The web host puts files in before a command reads them (a file picked or +//! dropped, an autosave kept in the browser's storage) and takes out what commands wrote +//! ([`MemoryFs::take_writes`]) to hand it out as a download or keep it in the browser's storage. +//! Natively the type is only used by its tests. +//! +//! Follows SolidCraft's `crates/io/src/vfs.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::{Error, Result}; + +/// Most files kept, and most bytes in all: a page that is handed more refuses the write. +pub const MAX_FILES: usize = 10_000; +pub const MAX_TOTAL_BYTES: u64 = 2 << 30; + +/// Files by path (shared, so handing one out copies nothing), their total size, and the paths +/// written since the host last took them. +#[derive(Debug, Default)] +pub struct MemoryFs { + files: BTreeMap>, + total: u64, + written: Vec, +} + +fn missing(path: &Path) -> Error { + Error::Io(format!("{}: no such file in this browser page", path.display())) +} + +impl MemoryFs { + fn room_for(&self, path: &Path, len: usize) -> Result<()> { + let replaced = self.files.get(path).map_or(0, |b| b.len() as u64); + let total = self.total.saturating_sub(replaced).saturating_add(len as u64); + if !self.files.contains_key(path) && self.files.len() >= MAX_FILES { + return Err(Error::Limit(format!("{}: more than {MAX_FILES} files in this browser page", path.display()))); + } + if total > MAX_TOTAL_BYTES { + return Err(Error::Limit(format!("{}: files in this browser page would take more than {} MB", path.display(), MAX_TOTAL_BYTES >> 20))); + } + Ok(()) + } + + fn put(&mut self, path: &Path, bytes: Arc<[u8]>) -> Result<()> { + self.room_for(path, bytes.len())?; + self.total = self.total.saturating_add(bytes.len() as u64); + if let Some(old) = self.files.insert(path.to_path_buf(), bytes) { + self.total = self.total.saturating_sub(old.len() as u64); + } + Ok(()) + } + + /// Put a file in from the host (not recorded as written). + pub fn insert(&mut self, path: &Path, bytes: Vec) -> Result<()> { + self.put(path, bytes.into()) + } + + /// Write a file for a command: kept, and recorded for [`MemoryFs::take_writes`]. + pub fn write(&mut self, path: &Path, bytes: &[u8]) -> Result<()> { + self.put(path, bytes.into())?; + self.written.retain(|p| p != path); + self.written.push(path.to_path_buf()); + Ok(()) + } + + /// A file of at most `max` bytes. + pub fn read(&self, path: &Path, max: u64) -> Result> { + let bytes = self.files.get(path).ok_or_else(|| missing(path))?; + if bytes.len() as u64 > max { + return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), bytes.len() >> 20, max >> 20))); + } + Ok(bytes.to_vec()) + } + + pub fn is_file(&self, path: &Path) -> bool { + self.files.contains_key(path) + } + + pub fn remove(&mut self, path: &Path) -> Result<()> { + let old = self.files.remove(path).ok_or_else(|| missing(path))?; + self.total = self.total.saturating_sub(old.len() as u64); + Ok(()) + } + + /// The files directly in `dir`, in path order. + pub fn list(&self, dir: &Path) -> Vec { + // Paths order by component, so everything under `dir` follows it. + self.files + .range(dir.to_path_buf()..) + .map(|(p, _)| p) + .take_while(|p| p.starts_with(dir)) + .filter(|p| p.parent() == Some(dir)) + .cloned() + .collect() + } + + /// The files written since the last call, in the order last written, with their content + /// (a file removed since is left out). + pub fn take_writes(&mut self) -> Vec<(PathBuf, Arc<[u8]>)> { + std::mem::take(&mut self.written).into_iter().filter_map(|p| self.files.get(&p).cloned().map(|b| (p, b))).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn files_are_kept_listed_and_handed_out_once_written() { + let mut m = MemoryFs::default(); + let (a, b) = (Path::new("/browser/a.fieldcraft"), Path::new("/browser/b.inp")); + m.insert(a, b"picked".to_vec()).unwrap(); + assert_eq!(m.read(a, 100).unwrap(), b"picked"); + assert!(m.take_writes().is_empty(), "a file put in by the host is not a write"); + m.write(b, b"one").unwrap(); + m.write(a, b"saved").unwrap(); + m.write(b, b"two").unwrap(); + let taken: Vec<(PathBuf, Vec)> = m.take_writes().into_iter().map(|(p, b)| (p, b.to_vec())).collect(); + assert_eq!(taken, vec![(a.to_path_buf(), b"saved".to_vec()), (b.to_path_buf(), b"two".to_vec())]); + assert!(m.take_writes().is_empty()); + assert_eq!(m.list(Path::new("/browser")), vec![a.to_path_buf(), b.to_path_buf()]); + assert!(m.list(Path::new("/")).is_empty(), "only the folder's own files"); + m.insert(Path::new("/browser2/c"), vec![]).unwrap(); + m.insert(Path::new("/browser/sub/d"), vec![]).unwrap(); + assert_eq!(m.list(Path::new("/browser")).len(), 2, "not a sibling folder's files, nor a subfolder's"); + m.write(a, b"again").unwrap(); + m.remove(a).unwrap(); + assert!(m.take_writes().is_empty(), "a removed file is not handed out"); + assert!(!m.is_file(a) && m.remove(a).is_err() && m.read(a, 100).is_err()); + } + + #[test] + fn reads_and_sizes_are_capped() { + let mut m = MemoryFs::default(); + let p = Path::new("/x"); + m.insert(p, vec![0; 100]).unwrap(); + assert!(matches!(m.read(p, 99), Err(Error::Limit(_)))); + assert_eq!(m.read(p, 100).map(|b| b.len()), Ok(100)); + for i in 1..MAX_FILES { + m.insert(&PathBuf::from(format!("/f{i}")), vec![]).unwrap(); + } + assert!(matches!(m.write(Path::new("/one-too-many"), b""), Err(Error::Limit(_)))); + assert!(m.write(p, b"replacing is fine").is_ok()); + assert_eq!(m.total, 17, "the total follows replacements"); + m.remove(p).unwrap(); + assert_eq!(m.total, 0); + } +} diff --git a/crates/io/src/recovery.rs b/crates/io/src/recovery.rs index 94caa69..9cb0137 100644 --- a/crates/io/src/recovery.rs +++ b/crates/io/src/recovery.rs @@ -6,20 +6,29 @@ //! nobody holds belongs to an app that crashed or closed with unsaved changes: [`orphans`] lists //! it for the next launch to offer. //! +//! In the browser the folder is [`BROWSER_DIR`] in the page's files ([`crate::memory`]), which +//! the web host keeps in the browser's storage, and an entry is held while the page that wrote it +//! is open (the page holds no file locks; another tab's entry counts as left behind). +//! //! Adapted from SolidCraft's `crates/engine/src/recovery.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. The engine decides when to autosave; this module only stores. -use std::fs::{self, File, OpenOptions}; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use crate::fs::{read_capped, write_atomic}; +use crate::fs::{create_dir_all, is_file, list, read_capped, remove as remove_file, write_atomic}; use crate::{EXTENSION, Error, MAX_FILE_BYTES, Result}; +/// The recovery folder of the browser build. +pub const BROWSER_DIR: &str = "/recovery"; + /// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else `FieldCraft/recovery` (`fieldcraft/` on -/// Linux) in the platform's per-user data folder. +/// Linux) in the platform's per-user data folder; [`BROWSER_DIR`] in the browser. pub fn default_dir() -> Option { + if cfg!(target_arch = "wasm32") { + return Some(PathBuf::from(BROWSER_DIR)); + } let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from); if let Some(d) = env("FIELDCRAFT_RECOVERY_DIR") { return Some(d); @@ -47,12 +56,28 @@ pub struct Entry { pub elements: usize, } -/// Seconds since 1970 (0 where the platform has no clock, such as the browser's plain wasm). -fn unix_now() -> u64 { +/// Milliseconds since 1970 from the host, where the platform has no clock of its own (the +/// browser's plain wasm); see [`set_clock`]. +static CLOCK: std::sync::OnceLock u64> = std::sync::OnceLock::new(); + +/// Give the module a clock (milliseconds since 1970) on a platform without one: the web host +/// passes the browser's. Entries then record when they were saved and get ids no other page +/// takes. The first clock given stays. +pub fn set_clock(now_ms: fn() -> u64) { + let _ = CLOCK.set(now_ms); +} + +/// Milliseconds since 1970 (0 without a clock). +fn unix_now_ms() -> u64 { if cfg!(target_arch = "wasm32") { - return 0; + return CLOCK.get().map_or(0, |now| now()); } - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis().try_into().unwrap_or(u64::MAX)).unwrap_or(0) +} + +/// Seconds since 1970 (0 without a clock). +fn unix_now() -> u64 { + unix_now_ms() / 1000 } fn valid_id(id: &str) -> bool { @@ -63,11 +88,106 @@ fn file(dir: &Path, id: &str, ext: &str) -> PathBuf { dir.join(format!("{id}.{ext}")) } -/// Whether a running app holds entry `id`'s lock. -fn held(dir: &Path, id: &str) -> bool { - File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err()) +/// Entry locks on the desktop: `.lock`, locked while the app runs. +#[cfg(not(target_arch = "wasm32"))] +mod lock { + use std::fs::{self, File, OpenOptions}; + use std::path::Path; + + use super::{MAX_LISTED, file}; + use crate::{Error, Result}; + + /// A held entry lock. + #[derive(Debug)] + pub struct Lock(#[allow(dead_code)] File); + + /// Whether a running app holds entry `id`'s lock. + pub fn held(dir: &Path, id: &str) -> bool { + File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err()) + } + + pub fn take(dir: &Path, id: &str) -> Result { + let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display())); + let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, id, "lock")).map_err(io)?; + lock.lock().map_err(io)?; + Ok(Lock(lock)) + } + + /// Remove the locks of apps that ended with nothing to recover. + pub fn sweep(dir: &Path) { + for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("lock") + && !p.with_extension("json").exists() + && File::open(&p).is_ok_and(|l| l.try_lock().is_ok()) + { + let _ = fs::remove_file(&p); + } + } + } + + /// A process-unique part of new entry ids. + pub fn instance() -> String { + std::process::id().to_string() + } + + /// Let go of the lock; its file goes unless an autosave stays for the next launch. + pub fn release(lock: Lock, dir: &Path, id: &str, keep: bool) { + drop(lock); + if !keep { + let _ = fs::remove_file(file(dir, id, "lock")); + } + } } +/// Entry "locks" in the browser: the entries this page holds, kept in memory (a page that is +/// gone holds none). +#[cfg(target_arch = "wasm32")] +mod lock { + use std::cell::RefCell; + use std::collections::BTreeSet; + use std::path::Path; + + use crate::Result; + + thread_local! { + static HELD: RefCell> = const { RefCell::new(BTreeSet::new()) }; + } + + /// Releases the entry when dropped (the slot is gone). + #[derive(Debug)] + pub struct Lock(String); + + impl Drop for Lock { + fn drop(&mut self) { + HELD.with(|h| h.borrow_mut().remove(&self.0)); + } + } + + pub fn held(_dir: &Path, id: &str) -> bool { + HELD.with(|h| h.borrow().contains(id)) + } + + pub fn take(_dir: &Path, id: &str) -> Result { + HELD.with(|h| h.borrow_mut().insert(id.to_string())); + Ok(Lock(id.to_string())) + } + + pub fn sweep(_dir: &Path) {} + + /// The page's part of new entry ids: the milliseconds of the clock (the id has its seconds), + /// so pages started in the same second take different ids (unless in the same millisecond). + pub fn instance() -> String { + format!("w{}", super::unix_now_ms() % 1000) + } + + pub fn release(lock: Lock, _dir: &Path, _id: &str, _keep: bool) { + drop(lock); + } +} + +use lock::held; + fn missing(id: &str) -> Error { Error::Io(format!("no recovered model `{}`", id.chars().take(64).collect::())) } @@ -84,17 +204,14 @@ fn read_entry(path: &Path) -> Option { /// Entries left by apps that are no longer running, newest first. pub fn orphans(dir: &Path) -> Vec { - let mut out: Vec = fs::read_dir(dir) + let mut out: Vec = list(dir) .into_iter() - .flatten() - .flatten() - .filter_map(|e| { - let p = e.path(); + .filter_map(|p| { if p.extension().and_then(|x| x.to_str()) != Some("json") { return None; } let entry = read_entry(&p)?; - if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !file(dir, &entry.id, EXTENSION).is_file() + if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !is_file(&file(dir, &entry.id, EXTENSION)) { return None; } @@ -126,7 +243,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> { return Err(Error::Io(format!("recovered model `{id}` belongs to a running app"))); } for ext in ["json", EXTENSION, "lock"] { - let _ = fs::remove_file(file(dir, id, ext)); + let _ = remove_file(&file(dir, id, ext)); } Ok(()) } @@ -136,32 +253,21 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> { pub struct Slot { dir: PathBuf, id: String, - lock: Option, + lock: Option, has_entry: bool, } impl Slot { /// Take a new entry in `dir` (created if needed), holding its lock. pub fn new(dir: &Path) -> Result { - let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display())); - fs::create_dir_all(dir).map_err(io)?; - // Locks of apps that ended with nothing to recover. - for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) { - let p = e.path(); - if p.extension().and_then(|x| x.to_str()) == Some("lock") - && !p.with_extension("json").exists() - && File::open(&p).is_ok_and(|l| l.try_lock().is_ok()) - { - let _ = fs::remove_file(&p); - } - } + create_dir_all(dir)?; + lock::sweep(dir); // Unique within the process too: an app started again in the same second (tests that // "crash" and restart) must not take over the crashed app's entry. static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let id = format!("{}-{}-{seq}", unix_now(), std::process::id()); - let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?; - lock.lock().map_err(io)?; + let id = format!("{}-{}-{seq}", unix_now(), lock::instance()); + let lock = lock::take(dir, &id)?; Ok(Slot { dir: dir.to_path_buf(), id, lock: Some(lock), has_entry: false }) } @@ -189,7 +295,7 @@ impl Slot { pub fn clear(&mut self) { if self.has_entry { for ext in ["json", EXTENSION] { - let _ = fs::remove_file(file(&self.dir, &self.id, ext)); + let _ = remove_file(&file(&self.dir, &self.id, ext)); } self.has_entry = false; } @@ -198,15 +304,16 @@ impl Slot { /// The app is closing: the lock is released; with no autosave the lock file goes too, while /// an autosave stays for the next launch to offer. pub fn close(mut self) { - self.lock = None; - if !self.has_entry { - let _ = fs::remove_file(file(&self.dir, &self.id, "lock")); + if let Some(lock) = self.lock.take() { + lock::release(lock, &self.dir, &self.id, self.has_entry); } } } #[cfg(test)] mod tests { + use std::fs; + use super::*; fn dir(tag: &str) -> PathBuf { diff --git a/docs/architecture.md b/docs/architecture.md index 92a0d4c..ad3f2a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ crates in strictly lower layers. | L2 | `fieldcraft-lbm` | Lattice Boltzmann flow solver on the CPU: D3Q19 two-relaxation-time scheme, bounce-back and slip walls, Zou–He inlets and outlets, obstacles voxelised from the face-tagged surface with exact predicates, momentum-exchange forces per face. Uses `mesh` for the predicates and the boundary surface. | started | | L3 | `fieldcraft-fsi` | Fluid–structure coupling between the L2 solvers (later). | planned | | L4 | `fieldcraft-doc` | The document: geometry, mesh, model, results, history. | planned | -| L5 | `fieldcraft-io` | `.fieldcraft` files (docs/formats/fieldcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery; read and write roots that confine an automation client's file access (`roots`). Bytes in, bytes out; the file system only in `fs`, `recovery` and `roots`. Result files later. | started | +| L5 | `fieldcraft-io` | `.fieldcraft` files (docs/formats/fieldcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery; read and write roots that confine an automation client's file access (`roots`). Bytes in, bytes out; the file system only in `fs`, `recovery` and `roots` (in the browser, files kept in memory: `memory`). Result files later. | started | | L6 | `fieldcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts; every file a command names checked against the session's roots (`Session::file_access`); headless pictures of the session (`view`: model, geometry, deformed shape and contours on the CPU rasterizer). | started | | L7 | `fieldcraft-ui-egui`, `fieldcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server over stdio (docs/mcp.md): tools over engine commands, a headless session or the running app through its control channel, read and write roots. | ui, mcp started | | testkit | `fieldcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started |