forked from 1b-agent/fieldcraft-upstream
io: files kept in memory in the browser build, recovery over them
The browser has no file system a page can call synchronously. On wasm32, fieldcraft_io::fs now reads and writes files kept in memory (memory.rs, after SolidCraft's vfs): the web host puts picked and dropped files in and takes out what commands wrote. The recovery folder works over the same calls (list, remove, is_file), with in-memory entry locks and a host-provided clock on wasm, so autosave and File > Recover work in the browser as on the desktop. Native behaviour is unchanged. Part of #119 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
7d942574fd
commit
8f27ad225a
6 files changed
+561
-209
No files matched your search
+262
-170
@@ -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<MemoryFs> = 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<Vec<u8>> {
|
||||
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<PathBuf> {
|
||||
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<u8>) -> 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
|
||||
/// `<path>.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<PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
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
|
||||
/// `<path>.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<PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf, Arc<[u8]>>,
|
||||
total: u64,
|
||||
written: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
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<u8>) -> 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<Vec<u8>> {
|
||||
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<PathBuf> {
|
||||
// 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<u8>)> = 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);
|
||||
}
|
||||
}
|
||||
+144
-37
@@ -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<PathBuf> {
|
||||
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<fn() -> 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: `<id>.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<Lock> {
|
||||
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<BTreeSet<String>> = 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<Lock> {
|
||||
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::<String>()))
|
||||
}
|
||||
@@ -84,17 +204,14 @@ fn read_entry(path: &Path) -> Option<Entry> {
|
||||
|
||||
/// Entries left by apps that are no longer running, newest first.
|
||||
pub fn orphans(dir: &Path) -> Vec<Entry> {
|
||||
let mut out: Vec<Entry> = fs::read_dir(dir)
|
||||
let mut out: Vec<Entry> = 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<File>,
|
||||
lock: Option<lock::Lock>,
|
||||
has_entry: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Take a new entry in `dir` (created if needed), holding its lock.
|
||||
pub fn new(dir: &Path) -> Result<Slot> {
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user