Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion crates/wasmsh-fs/src/emscripten_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::ffi::CString;
use std::io::Read;
use std::rc::Rc;

use crate::{DirEntry, FileHandle, FsError, Metadata, OpenOptions, Vfs, VfsWriteSink};
use crate::{DirEntry, FileHandle, FsChangeLog, FsError, Metadata, OpenOptions, Vfs, VfsWriteSink};

/// A filesystem backed by Emscripten's POSIX layer.
///
Expand All @@ -20,6 +20,7 @@ pub struct EmscriptenFs {
virtual_readers: HashMap<String, Rc<RefCell<Box<dyn Read>>>>,
/// Maps our `FileHandle` to (libc `FILE*`, path, open-for-write).
open_files: HashMap<u64, OpenFile>,
change_log: FsChangeLog,
}

impl std::fmt::Debug for EmscriptenFs {
Expand All @@ -28,6 +29,7 @@ impl std::fmt::Debug for EmscriptenFs {
.field("next_handle", &self.next_handle)
.field("virtual_reader_count", &self.virtual_readers.len())
.field("open_files", &self.open_files)
.field("change_log", &self.change_log)
.finish()
}
}
Expand Down Expand Up @@ -166,6 +168,7 @@ impl EmscriptenFs {
next_handle: 1,
virtual_readers: HashMap::new(),
open_files: HashMap::new(),
change_log: FsChangeLog::new(),
}
}
}
Expand All @@ -176,6 +179,9 @@ impl Clone for EmscriptenFs {
next_handle: 1,
virtual_readers: self.virtual_readers.clone(),
open_files: HashMap::new(),
// Share the same underlying change log so writes performed via a
// clone still surface to the host that drains the original.
change_log: self.change_log.clone(),
}
}
}
Expand Down Expand Up @@ -247,6 +253,10 @@ impl Vfs for EmscriptenFs {
return Err(FsError::PermissionDenied(path.to_string()));
}

if opts.write || opts.append || opts.create || opts.truncate {
self.change_log.record(path);
}

let h = self.next_handle;
self.next_handle += 1;
self.open_files.insert(
Expand Down Expand Up @@ -352,6 +362,7 @@ impl Vfs for EmscriptenFs {
if written != data.len() {
return Err(FsError::Io("short write".into()));
}
self.change_log.record(path);
Ok(())
}
OpenFileSource::Virtual(_) => Err(FsError::PermissionDenied(
Expand All @@ -376,6 +387,7 @@ impl Vfs for EmscriptenFs {
if fp.is_null() {
return Err(errno_to_fs_error(path));
}
self.change_log.record(path);
Ok(Box::new(EmscriptenWriteSink { fp }))
}

Expand Down Expand Up @@ -443,6 +455,7 @@ impl Vfs for EmscriptenFs {
if rc != 0 {
return Err(errno_to_fs_error(path));
}
self.change_log.record(path);
Ok(())
}

Expand All @@ -454,6 +467,7 @@ impl Vfs for EmscriptenFs {
if unsafe { libc::unlink(cpath.as_ptr()) } != 0 {
return Err(errno_to_fs_error(path));
}
self.change_log.record(path);
Ok(())
}

Expand All @@ -462,6 +476,11 @@ impl Vfs for EmscriptenFs {
if unsafe { libc::rmdir(cpath.as_ptr()) } != 0 {
return Err(errno_to_fs_error(path));
}
self.change_log.record(path);
Ok(())
}

fn change_log(&self) -> Option<&FsChangeLog> {
Some(&self.change_log)
}
}
64 changes: 64 additions & 0 deletions crates/wasmsh-fs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

#![warn(missing_docs)]

use std::cell::RefCell;
use std::collections::HashSet;
use std::io::Read;
use std::rc::Rc;

#[cfg(feature = "emscripten")]
#[allow(unsafe_code, clippy::borrow_as_ptr)]
Expand Down Expand Up @@ -139,6 +142,58 @@ impl OpenOptions {
}
}

/// Records the paths a filesystem backend mutated, so a host can learn which
/// files changed during a run and re-read or re-render them.
///
/// The log preserves first-seen insertion order and de-duplicates paths, so a
/// file written several times within a single run surfaces once. It is cheap
/// to clone (`Rc`-backed): backends keep a clone and the host drains the same
/// underlying log with [`FsChangeLog::take`].
#[derive(Debug, Clone, Default)]
pub struct FsChangeLog {
inner: Rc<RefCell<FsChangeLogInner>>,
}

#[derive(Debug, Default)]
struct FsChangeLogInner {
order: Vec<String>,
seen: HashSet<String>,
}

impl FsChangeLog {
/// Create a new, empty change log.
#[must_use]
pub fn new() -> Self {
Self::default()
}

/// Record that `path` was created, modified, or removed.
///
/// Duplicate paths are ignored after the first record within a drain
/// window, preserving the order in which paths were first touched.
pub fn record(&self, path: &str) {
let mut inner = self.inner.borrow_mut();
if inner.seen.insert(path.to_string()) {
inner.order.push(path.to_string());
}
}

/// Drain and return the recorded paths in first-seen order, resetting the
/// log so the next run starts empty.
#[must_use]
pub fn take(&self) -> Vec<String> {
let mut inner = self.inner.borrow_mut();
inner.seen.clear();
std::mem::take(&mut inner.order)
}

/// Return `true` if no paths are currently recorded.
#[must_use]
pub fn is_empty(&self) -> bool {
self.inner.borrow().order.is_empty()
}
}

/// Virtual filesystem trait.
pub trait VfsWriteSink {
/// Write a chunk to the sink.
Expand Down Expand Up @@ -178,6 +233,15 @@ pub trait Vfs {
fn remove_file(&mut self, path: &str) -> Result<(), FsError>;
/// Remove the empty directory at `path`.
fn remove_dir(&mut self, path: &str) -> Result<(), FsError>;

/// Access this backend's filesystem change log, if it records mutations.
///
/// Backends that track writes return a handle the host can drain (via
/// [`FsChangeLog::take`]) to learn which paths changed during a run. The
/// default returns `None` for backends that do not track changes.
fn change_log(&self) -> Option<&FsChangeLog> {
None
}
}

/// An opaque file handle.
Expand Down
75 changes: 73 additions & 2 deletions crates/wasmsh-fs/src/memfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::io::{Cursor, Read};
use std::rc::Rc;
use std::sync::Arc;

use crate::{DirEntry, FileHandle, FsError, Metadata, OpenOptions, Vfs, VfsWriteSink};
use crate::{DirEntry, FileHandle, FsChangeLog, FsError, Metadata, OpenOptions, Vfs, VfsWriteSink};

/// Maximum file size (64 MiB).
const MAX_FILE_SIZE: usize = 64 * 1024 * 1024;
Expand Down Expand Up @@ -96,6 +96,7 @@ impl std::fmt::Debug for MemoryFsInner {
#[derive(Debug, Clone)]
pub struct MemoryFs {
inner: Rc<RefCell<MemoryFsInner>>,
change_log: FsChangeLog,
}

struct MemoryWriteSink {
Expand All @@ -118,6 +119,7 @@ impl MemoryFs {
next_handle: 1,
total_bytes: 0,
})),
change_log: FsChangeLog::new(),
}
}

Expand Down Expand Up @@ -263,6 +265,10 @@ impl Vfs for MemoryFs {
}
drop(inner);

if opts.write || opts.append || opts.create || opts.truncate {
self.change_log.record(&norm);
}

let h = self.alloc_handle();
self.inner.borrow_mut().handles.insert(
h,
Expand Down Expand Up @@ -355,6 +361,8 @@ impl Vfs for MemoryFs {
let append = of.opts.append;
drop(inner);

self.change_log.record(&path);

MemoryWriteSink {
inner: Rc::clone(&self.inner),
path,
Expand Down Expand Up @@ -396,6 +404,7 @@ impl Vfs for MemoryFs {
.nodes
.insert(norm.clone(), FsNode::File(Arc::from([])));
}
self.change_log.record(&norm);
Ok(Box::new(MemoryWriteSink {
inner: Rc::clone(&self.inner),
path: norm,
Expand Down Expand Up @@ -487,7 +496,9 @@ impl Vfs for MemoryFs {
self.ensure_parents(&norm)?;
let mut inner = self.inner.borrow_mut();
check_inode_room(&inner)?;
inner.nodes.insert(norm, FsNode::Dir);
inner.nodes.insert(norm.clone(), FsNode::Dir);
drop(inner);
self.change_log.record(&norm);
Ok(())
}

Expand All @@ -505,6 +516,8 @@ impl Vfs for MemoryFs {
inner.nodes.remove(&norm);
inner.virtual_readers.remove(&norm);
inner.total_bytes = inner.total_bytes.saturating_sub(size);
drop(inner);
self.change_log.record(&norm);
Ok(())
}
None => Err(FsError::NotFound(norm)),
Expand Down Expand Up @@ -534,8 +547,13 @@ impl Vfs for MemoryFs {
return Err(FsError::Io(format!("directory not empty: {norm}")));
}
self.inner.borrow_mut().nodes.remove(&norm);
self.change_log.record(&norm);
Ok(())
}

fn change_log(&self) -> Option<&FsChangeLog> {
Some(&self.change_log)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -761,4 +779,57 @@ mod tests {
let h = fs.open("/log.txt", OpenOptions::read()).unwrap();
assert_eq!(fs.read_file(h).unwrap(), b"line1\nline2\n");
}

#[test]
fn change_log_records_writes_creates_and_removals() {
let mut fs = MemoryFs::new();
let log = fs.change_log().expect("MemoryFs tracks changes").clone();

// Write via open(write) + write_file.
let h = fs.open("/a.txt", OpenOptions::write()).unwrap();
fs.write_file(h, b"hi").unwrap();
fs.close(h);
// Write via streaming sink.
let mut sink = fs.open_write_sink("/b.txt", false).unwrap();
sink.write(b"x").unwrap();
drop(sink);
fs.create_dir("/dir").unwrap();
fs.remove_file("/a.txt").unwrap();
fs.remove_dir("/dir").unwrap();

let changed = log.take();
assert_eq!(changed, vec!["/a.txt", "/b.txt", "/dir"]);
// Draining resets the log.
assert!(log.take().is_empty());
}

#[test]
fn change_log_dedups_repeated_writes_in_first_seen_order() {
let mut fs = MemoryFs::new();
let log = fs.change_log().expect("MemoryFs tracks changes").clone();

for path in ["/one", "/two", "/one"] {
let h = fs.open(path, OpenOptions::write()).unwrap();
fs.write_file(h, b"data").unwrap();
fs.close(h);
}

assert_eq!(log.take(), vec!["/one", "/two"]);
}

#[test]
fn change_log_ignores_read_only_opens() {
let mut fs = MemoryFs::new();
let h = fs.open("/seed.txt", OpenOptions::write()).unwrap();
fs.write_file(h, b"data").unwrap();
fs.close(h);
let log = fs.change_log().unwrap().clone();
let _ = log.take();

let h = fs.open("/seed.txt", OpenOptions::read()).unwrap();
let _ = fs.read_file(h).unwrap();
fs.close(h);

assert!(log.take().is_empty());
}
}
Loading