// Does the editor touch files the user has not edited? // // Every test here runs against a real git repository on disk (a docs folder with subfolders, an // assets folder, a .gitignore and a vendored node_modules) rather than against a TempDir, because // the promise being checked is a promise about the user's own folder: the evidence that nothing was // touched is `git status` being empty, plus a byte-level snapshot of every path under the root // including .git itself. // // Run single threaded. The tests share one folder and several of them mutate it. // // cargo test --test no_write_on_open -- --test-threads=1 --nocapture use std::collections::BTreeMap; use std::fs; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use margin_docs_lib::dto::{FileNode, WatchEvent}; use margin_docs_lib::fs::{ read_document, resolve_in_roots, root_id_for, scan_tree, write_document, }; use margin_docs_lib::watch::spawn_watcher; const REPO: &str = "/private/tmp/margin-notouch/notes-repo"; // ---------------------------------------------------------------- fixture fn repo() -> PathBuf { let path = PathBuf::from(REPO); assert!( path.join(".git").is_dir(), "the fixture repo is missing: {REPO}" ); path } fn git(args: &[&str]) -> String { let out = Command::new("git") .args(args) .current_dir(REPO) .output() .expect("git runs"); let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); text.push_str(&String::from_utf8_lossy(&out.stderr)); text } /// Back to the committed state, then one warm `git status` so the index's stat cache is already /// refreshed and a later `git status` is not itself the thing that wrote to .git. fn pristine() -> PathBuf { let root = repo(); git(&["reset", "--hard", "-q"]); git(&["clean", "-fdq"]); let status = git(&["status", "--porcelain"]); assert!( status.is_empty(), "the fixture repo did not start clean:\n{status}" ); root } fn git_status() -> String { git(&["status", "--porcelain"]) } // ---------------------------------------------------------------- snapshots #[derive(Clone, PartialEq, Eq, Debug)] struct Stamp { kind: &'static str, len: u64, mtime: (i64, i64), ctime: (i64, i64), ino: u64, mode: u32, nlink: u64, /// Content hash, for everything outside node_modules. `None` for directories and for the /// 11,000 junk files, where size plus both timestamps plus the inode is already conclusive. hash: Option, } type Snapshot = BTreeMap; fn fnv1a(bytes: &[u8]) -> u64 { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in bytes { hash ^= *byte as u64; hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } hash } /// Every path under `root`, dotfiles, .git and node_modules included. Deliberately not the `ignore` /// crate: the point is to see everything the app might have touched, including what it hides. fn snapshot(root: &Path) -> Snapshot { let mut out = Snapshot::new(); walk(root, root, &mut out); out } fn walk(root: &Path, dir: &Path, out: &mut Snapshot) { let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let rel = path .strip_prefix(root) .unwrap_or(&path) .to_string_lossy() .into_owned(); let Ok(meta) = fs::symlink_metadata(&path) else { continue; }; let kind = if meta.is_dir() { "dir" } else if meta.is_symlink() { "link" } else { "file" }; let cheap = rel.starts_with("node_modules/") || rel.starts_with(".git/"); let hash = if kind == "file" && !cheap { fs::read(&path).ok().map(|bytes| fnv1a(&bytes)) } else { None }; out.insert( rel, Stamp { kind, len: meta.len(), mtime: (meta.mtime(), meta.mtime_nsec()), ctime: (meta.ctime(), meta.ctime_nsec()), ino: meta.ino(), mode: meta.mode(), nlink: meta.nlink(), hash, }, ); if meta.is_dir() { walk(root, &path, out); } } } #[derive(Default, Debug)] struct Diff { added: Vec, removed: Vec, changed: Vec, } impl Diff { fn is_empty(&self) -> bool { self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty() } } fn diff(before: &Snapshot, after: &Snapshot) -> Diff { let mut out = Diff::default(); for (path, stamp) in after { match before.get(path) { None => out.added.push(path.clone()), Some(was) if was != stamp => out.changed.push(format!( "{path}\n was {was:?}\n now {stamp:?}" )), Some(_) => {} } } for path in before.keys() { if !after.contains_key(path) { out.removed.push(path.clone()); } } out } fn assert_untouched(label: &str, before: &Snapshot, after: &Snapshot) { let d = diff(before, after); assert!( d.is_empty(), "{label} touched the folder\n added: {:?}\n removed: {:?}\n changed:\n {}", d.added, d.removed, d.changed.join("\n ") ); println!(" [{label}] {} paths, none touched", before.len()); } fn assert_clean(label: &str) { let status = git_status(); println!(" [{label}] git status --porcelain:\n{}", quoted(&status)); assert!(status.is_empty(), "{label} left git dirty:\n{status}"); } fn quoted(status: &str) -> String { if status.is_empty() { " ".to_string() } else { status .lines() .map(|line| format!(" {line}")) .collect::>() .join("\n") } } // ---------------------------------------------------------------- tree helpers fn flatten(node: &FileNode, into: &mut Vec) { into.push(node.path.clone()); for child in &node.children { flatten(child, into); } } fn all_paths(node: &FileNode) -> Vec { let mut out = Vec::new(); flatten(node, &mut out); out } fn child_names(node: &FileNode) -> Vec { node.children.iter().map(|c| c.name.clone()).collect() } /// What `file_read` does, minus the Tauri `State`: the same path gate, then the same read. fn file_read(root: &Path, path: &str) -> Result { let roots = vec![root.to_string_lossy().into_owned()]; let checked = resolve_in_roots(&roots, path)?; read_document(&checked).map(|r| r.text) } // ================================================================ 1. scanning #[test] fn scanning_the_tree_writes_nothing() { let root = pristine(); let before = snapshot(&root); let started = Instant::now(); let tree = scan_tree(&root, false).expect("a tree"); let took = started.elapsed(); let after = snapshot(&root); println!("scan_tree: {} rows in {took:?}", all_paths(&tree).len()); println!("root children: {:?}", child_names(&tree)); assert_untouched("scan_tree", &before, &after); assert_clean("scan_tree"); // The root id is derived from the path, never persisted into the folder. println!("root id: {}", root_id_for(&root.to_string_lossy())); } // ================================================================ 2. reading #[test] fn reading_every_document_writes_nothing() { let root = pristine(); let tree = scan_tree(&root, false).expect("a tree"); let mut docs: Vec = Vec::new(); collect_editable(&tree, &mut docs); assert!(docs.len() >= 10, "expected a realistic docs folder, got {docs:?}"); let before = snapshot(&root); let mut bytes = 0usize; for path in &docs { let text = file_read(&root, path).unwrap_or_else(|e| panic!("read {path}: {e}")); bytes += text.len(); } let after = snapshot(&root); println!("read {} documents, {bytes} bytes", docs.len()); assert_untouched("file_read x N", &before, &after); assert_clean("file_read x N"); } fn collect_editable(node: &FileNode, into: &mut Vec) { if node.editable { into.push(node.path.clone()); } for child in &node.children { collect_editable(child, into); } } // ================================================================ 3. open then close #[test] fn opening_a_document_and_closing_it_writes_nothing() { let root = pristine(); let doc = root.join("docs/architecture.md"); let path = doc.to_string_lossy().into_owned(); let before = snapshot(&root); // Open: the store's `open` calls `loadDocument`, which is one `file_read` and nothing else. let text = file_read(&root, &path).expect("the document"); assert!(!text.is_empty()); // Close: `useDocument.close()` flushes only when the buffer is dirty, and it is not. Nothing // reaches the backend at all, so there is nothing to call here. That absence is the test. let after = snapshot(&root); assert_untouched("open then close", &before, &after); assert_clean("open then close"); } // ================================================================ 4. the watcher #[test] fn the_watcher_does_not_cause_a_write() { let root = pristine(); let (tx, rx) = mpsc::channel::(); let before = snapshot(&root); let watcher = spawn_watcher( "root-1".to_string(), root.to_string_lossy().into_owned(), move |events| { for event in events { tx.send(event).ok(); } }, ) .expect("a watcher"); // Long enough for a watcher that was going to write something to have written it: FSEvents // establishes its stream, the debouncer ticks every 75ms, and 3s is ten times its window. std::thread::sleep(Duration::from_secs(3)); // And a full read pass while the watch is live, which is the shape of real use. let tree = scan_tree(&root, false).expect("a tree"); let mut docs = Vec::new(); collect_editable(&tree, &mut docs); for path in &docs { file_read(&root, path).expect("a read"); } std::thread::sleep(Duration::from_secs(2)); let after = snapshot(&root); drop(watcher); let events: Vec = rx.try_iter().collect(); println!( "watcher ran 5s over {} paths and emitted {} events: {:?}", before.len(), events.len(), events .iter() .map(|e| format!("{} {}", e.kind, e.path)) .collect::>() ); assert!( events.is_empty(), "the watcher reported changes when nothing changed" ); assert_untouched("watcher idle + reads", &before, &after); assert_clean("watcher idle + reads"); } // ================================================================ 5. node_modules #[test] fn node_modules_is_skipped_rather_than_walked() { let root = pristine(); let junk = root.join("node_modules"); let junk_files = snapshot(&junk).len(); assert!(junk_files > 5_000, "the fixture needs a real node_modules"); let started = Instant::now(); let skipped = scan_tree(&root, false).expect("a tree"); let skipped_ms = started.elapsed(); let skipped_rows = all_paths(&skipped).len(); let started = Instant::now(); let walked = scan_tree(&root, true).expect("a tree"); let walked_ms = started.elapsed(); let walked_rows = all_paths(&walked).len(); let started = Instant::now(); let alone = scan_tree(&junk, true).expect("a tree"); let alone_ms = started.elapsed(); println!("node_modules holds {junk_files} entries"); println!("scan_tree(show_ignored=false): {skipped_rows} rows in {skipped_ms:?}"); println!("scan_tree(show_ignored=true): {walked_rows} rows in {walked_ms:?}"); println!( "scan_tree(node_modules alone): {} rows in {alone_ms:?}", all_paths(&alone).len() ); assert!( !child_names(&skipped).contains(&"node_modules".to_string()), "node_modules is in the tree: {:?}", child_names(&skipped) ); assert!( !all_paths(&skipped) .iter() .any(|p| p.contains("node_modules")), "something under node_modules reached the tree" ); assert!( skipped_rows < 40, "the skipped scan returned {skipped_rows} rows, so it walked more than the documents" ); assert!( child_names(&walked).contains(&"node_modules".to_string()), "show_ignored did not turn the skip off" ); assert!( walked_rows > 10_000, "show_ignored=true only found {walked_rows} rows" ); // Skipping is not merely a filter over a walk that happened anyway. assert!( skipped_ms * 10 < walked_ms, "skipping node_modules ({skipped_ms:?}) was not much faster than walking it ({walked_ms:?})" ); // And walking it, when asked to, is not absurd either. assert!( walked_ms < Duration::from_secs(20), "walking node_modules took {walked_ms:?}" ); let after = snapshot(&root); assert_eq!(after.len(), snapshot(&root).len()); assert_clean("three scans"); } // ================================================================ 6. one write, one file #[test] fn a_write_to_one_file_leaves_every_other_file_alone() { let root = pristine(); let target = root.join("docs/architecture.md"); let read = read_document(&target).expect("the document"); let before = snapshot(&root); let result = write_document(&target, &format!("{}\n\nAn edit.\n", read.text), Some(read.modified_ms)) .expect("the write"); assert!(!result.conflict); let after = snapshot(&root); let d = diff(&before, &after); println!("added: {:?}", d.added); println!("removed: {:?}", d.removed); println!( "changed: {:?}", d.changed .iter() .map(|c| c.lines().next().unwrap_or_default().to_string()) .collect::>() ); let status = git_status(); println!("git status --porcelain after one write:\n{}", quoted(&status)); // .git moves because git status re-reads it; that is git's doing, not the editor's. let touched: Vec<&String> = d .changed .iter() .filter(|c| !c.starts_with(".git/") && !c.starts_with(".git\n")) .collect(); assert!(d.added.is_empty(), "the write added files: {:?}", d.added); assert!( d.removed.is_empty(), "the write removed files: {:?}", d.removed ); assert_eq!( touched.len(), 2, "expected only docs/architecture.md and its parent dir to move, got:\n{}", d.changed.join("\n") ); assert_eq!( status.lines().collect::>(), vec![" M docs/architecture.md"], "one edit should be one line of git status" ); } // ================================================================ 7. the .bak #[test] fn a_write_leaves_no_bak_behind_once_it_has_finished() { let root = pristine(); let target = root.join("docs/guides/mobile.md"); let read = read_document(&target).expect("the document"); write_document(&target, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) .expect("the write"); let leftovers: Vec = snapshot(&root) .keys() .filter(|p| !p.starts_with(".git/")) .filter(|p| p.ends_with(".bak") || p.ends_with(".tmp")) .cloned() .collect(); println!("*.bak and *.tmp under the root after a completed write: {leftovers:?}"); // The two the fixture committed on purpose are the user's own and must still be there. assert!(leftovers.contains(&"docs/design.md.bak".to_string())); assert!(leftovers.contains(&"docs/conventions.md.tmp".to_string())); assert_eq!( leftovers.len(), 2, "the write left a backup or temp file behind: {leftovers:?}" ); println!("git status --porcelain:\n{}", quoted(&git_status())); } /// Nothing visible may appear inside the user's folder while a write is running, because git, /// Dropbox, a backup tool and the app's own watcher are all looking at that folder too. #[test] fn a_save_puts_nothing_visible_in_the_folder_even_for_an_instant() { let root = pristine(); let target = root.join("docs/big.md"); // Big enough that the copy-then-fsync window is wide enough to observe. Untracked, and removed // at the end of the test. let payload = "x".repeat(48 * 1024 * 1024); fs::write(&target, &payload).expect("the fixture document"); // This test used to assert the opposite. It was written to prove the bug that a save put a // `docs/big.md.bak` and a `docs/big.md.tmp` inside the user's folder, where git, a backup tool // or another editor would see them, and where a crash stranded them. The `.bak` is gone and the // temp file is now hidden and uniquely named, so the same window is now the proof of the fix. let docs = root.join("docs"); let before: Vec = fs::read_dir(&docs) .expect("the docs folder") .flatten() .map(|entry| entry.file_name().to_string_lossy().into_owned()) .collect(); let sightings = Arc::new(Mutex::new(Vec::::new())); let watching = sightings.clone(); let (stop_tx, stop_rx) = mpsc::channel::<()>(); let poller = std::thread::spawn(move || { while stop_rx.try_recv().is_err() { let Ok(entries) = fs::read_dir(&docs) else { continue }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); if name == "big.md" { continue; } let mut seen = watching.lock().unwrap(); if !seen.contains(&name) { seen.push(name); } } } }); let second = format!("{payload}y"); write_document(&target, &second, None).expect("the write"); stop_tx.send(()).ok(); poller.join().ok(); let seen = sightings.lock().unwrap().clone(); println!("seen inside docs/ during one save: {seen:?}"); println!("git status --porcelain after the save:\n{}", quoted(&git_status())); fs::remove_file(&target).ok(); // Diff against what was there before rather than an allowlist, because two of the files that // survive a save are the planted user owned `design.md.bak` and `conventions.md.tmp`, and // hardcoding those would mean this test stopped noticing if a save started creating them. let from_the_save: Vec<&String> = seen.iter().filter(|name| !before.contains(*name)).collect(); assert!( from_the_save.iter().all(|name| name.starts_with('.')), "a save must not put a visible file in the user's folder, even for an instant: {from_the_save:?}" ); assert!( !from_the_save.iter().any(|name| name.ends_with(".bak")), "the .bak rotation is gone and must stay gone: {from_the_save:?}" ); assert!( from_the_save.iter().all(|name| !name.starts_with(".big.md.tmp")), "the temp name must carry the pid and a stamp so two saves cannot collide: {from_the_save:?}" ); } /// The same window, but seen through the app's own watcher: does a `.bak` path reach the frontend? #[test] fn the_watcher_reports_the_bak_and_tmp_to_the_frontend() { let root = pristine(); let (tx, rx) = mpsc::channel::(); let watcher = spawn_watcher( "root-1".to_string(), root.to_string_lossy().into_owned(), move |events| { for event in events { tx.send(event).ok(); } }, ) .expect("a watcher"); std::thread::sleep(Duration::from_secs(2)); while rx.try_recv().is_ok() {} let target = root.join("docs/design.md"); let read = read_document(&target).expect("the document"); write_document(&target, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) .expect("the write"); std::thread::sleep(Duration::from_secs(3)); drop(watcher); let events: Vec = rx .try_iter() .map(|e| format!("{} {}", e.kind, e.path)) .collect(); println!("watch events for one save of docs/design.md:"); for event in &events { println!(" {event}"); } println!("git status --porcelain:\n{}", quoted(&git_status())); // Not an assertion about what is right, just a record of what the frontend is handed. let noise: Vec<&String> = events .iter() .filter(|e| e.ends_with(".bak") || e.ends_with(".tmp")) .collect(); println!("of which temp/backup paths: {noise:?}"); } // ================================================================ 8. collateral /// A write to `x.md` unlinks `x.md.bak` and `x.md.tmp` if the user happens to have files by those /// names. Neither is a file the user edited, and neither goes to the Trash. #[test] fn a_write_deletes_a_sibling_bak_the_user_owns() { let root = pristine(); let bak = root.join("docs/design.md.bak"); let tmp = root.join("docs/conventions.md.tmp"); let bak_text = fs::read_to_string(&bak).expect("the user's own backup"); let tmp_text = fs::read_to_string(&tmp).expect("the user's own scratch file"); println!("before: docs/design.md.bak = {bak_text:?}"); println!("before: docs/conventions.md.tmp = {tmp_text:?}"); println!("both are tracked: {}", git(&["ls-files", "docs/"]).replace('\n', " ")); let design = root.join("docs/design.md"); let read = read_document(&design).expect("the document"); write_document(&design, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) .expect("the write"); let conventions = root.join("docs/conventions.md"); let read = read_document(&conventions).expect("the document"); write_document( &conventions, &format!("{}\nedited\n", read.text), Some(read.modified_ms), ) .expect("the write"); let status = git_status(); println!("git status --porcelain after editing design.md and conventions.md:\n{}", quoted(&status)); let bak_gone = !bak.exists(); let tmp_gone = !tmp.exists(); println!("docs/design.md.bak still there: {}", !bak_gone); println!("docs/conventions.md.tmp still there: {}", !tmp_gone); assert!( !bak_gone && !tmp_gone, "editing design.md and conventions.md deleted files the user never opened:\n{status}" ); } // ================================================================ 9. is it really skipped? /// Timing says node_modules is skipped rather than filtered after the fact. Taking away the right /// to read it says so without a stopwatch: a walk that entered it would come back with rows from /// it, or with an error, and this one comes back with neither. #[test] fn node_modules_is_not_entered_at_all() { let root = pristine(); let junk = root.join("node_modules"); let locked = fs::Permissions::from_mode(0o000); let open = fs::metadata(&junk).expect("node_modules").permissions(); fs::set_permissions(&junk, locked).expect("lock node_modules"); let started = Instant::now(); let tree = scan_tree(&root, false); let took = started.elapsed(); fs::set_permissions(&junk, open).expect("unlock node_modules"); let tree = tree.expect("an unreadable node_modules must not fail the scan"); let rows = all_paths(&tree); println!("scan with node_modules at mode 000: {} rows in {took:?}", rows.len()); println!("root children: {:?}", child_names(&tree)); assert!(!rows.iter().any(|p| p.contains("node_modules"))); assert_eq!(rows.len(), 21); assert_clean("scan with node_modules locked"); } use std::os::unix::fs::PermissionsExt; // ================================================================ 10. atime /// A read is not a write, but it does bump the access time on some volumes. Recorded rather than /// asserted: the question is whether anything about the file the user can see has changed, and /// mtime, ctime, size and bytes are what that means. #[test] fn what_a_read_actually_changes() { let root = pristine(); let doc = root.join("docs/architecture.md"); let before = fs::metadata(&doc).expect("the document"); std::thread::sleep(Duration::from_millis(50)); let _ = file_read(&root, &doc.to_string_lossy()).expect("the read"); let after = fs::metadata(&doc).expect("the document"); println!( "atime {}.{:09} -> {}.{:09} ({})", before.atime(), before.atime_nsec(), after.atime(), after.atime_nsec(), if (before.atime(), before.atime_nsec()) == (after.atime(), after.atime_nsec()) { "unchanged" } else { "bumped by the read" } ); assert_eq!(before.mtime_nsec(), after.mtime_nsec()); assert_eq!(before.ctime_nsec(), after.ctime_nsec()); assert_eq!(before.len(), after.len()); assert_clean("one read"); } // ================================================================ 11. two saves at once /// The temp file and the backup are named after the target and nothing else, so two writes to one /// document race on the same two paths, and the loser of the race can take the document with it. /// A debounced autosave landing while Cmd+S is in flight is exactly that shape. #[test] fn concurrent_saves_of_one_document() { const TRIALS: usize = 15; const PAYLOAD: usize = 2 * 1024 * 1024; let mut worst: Vec = Vec::new(); for writers in [2usize, 3, 4, 8] { let mut lost = 0; let mut torn = 0; let mut leftovers: Vec = Vec::new(); let mut first_status = String::new(); for _ in 0..TRIALS { let root = pristine(); let doc = root.join("docs/guides/setup.md"); let seen = read_document(&doc).expect("the document").modified_ms; // Every save carries the same expected mtime, because in the app they all come from the // same read: the conflict guard lets all of them through. let payloads: Vec = (0..writers) .map(|n| format!("save number {n}\n{}\n", "z".repeat(PAYLOAD))) .collect(); let handles: Vec<_> = payloads .iter() .cloned() .map(|text| { let doc = doc.clone(); std::thread::spawn(move || write_document(&doc, &text, Some(seen)).err()) }) .collect(); let _errors: Vec = handles .into_iter() .filter_map(|h| h.join().expect("a thread")) .collect(); match fs::read_to_string(&doc).ok() { None => { lost += 1; if first_status.is_empty() { first_status = git_status(); } } Some(text) if !payloads.contains(&text) => torn += 1, Some(_) => {} } for path in snapshot(&root).keys() { if path.starts_with(".git/") { continue; } let owned = path == "docs/conventions.md.tmp" || path == "docs/design.md.bak"; if (path.ends_with(".bak") || path.ends_with(".tmp")) && !owned { leftovers.push(path.clone()); } } } println!( "{writers} concurrent saves x {TRIALS} trials: {lost} destroyed the document, {torn} left it torn, leftovers {leftovers:?}" ); if !first_status.is_empty() { println!(" git status --porcelain from a trial that lost it:\n{}", quoted(&first_status)); } if lost > 0 || torn > 0 || !leftovers.is_empty() { worst.push(format!( "{writers} writers: {lost} destroyed, {torn} torn, leftovers {leftovers:?}" )); } } pristine(); assert!( worst.is_empty(), "concurrent saves of one document are not safe:\n {}", worst.join("\n ") ); } /// Four writers is more overlap than the app is likely to produce. Two is not: an autosave whose /// write is still in flight and a Cmd+S is two. This walks the second save's start across the /// first save's whole duration looking for the interleaving, rather than hoping to land on it. /// It has not found one here, which bounds how likely the two-writer case is without making it /// safe: the destructive window is between two adjacent renames and is sub-microsecond wide. #[test] fn two_concurrent_saves_across_the_whole_window() { const PAYLOAD: usize = 8 * 1024 * 1024; let mut losses = Vec::new(); for delay_us in (0..40_000u64).step_by(500) { let root = pristine(); let doc = root.join("docs/guides/setup.md"); let seen = read_document(&doc).expect("the document").modified_ms; let big = format!("the autosave\n{}\n", "z".repeat(PAYLOAD)); let small = "the Cmd+S\n".to_string(); let first = { let doc = doc.clone(); std::thread::spawn(move || write_document(&doc, &big, Some(seen)).err()) }; let second = { let doc = doc.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_micros(delay_us)); write_document(&doc, &small, Some(seen)).err() }) }; let a = first.join().expect("a thread"); let b = second.join().expect("a thread"); if !doc.exists() { losses.push(format!( "second save started {delay_us}us in: document destroyed; errors {a:?} / {b:?}; git status {:?}", git_status().trim().to_string() )); if losses.len() >= 3 { break; } } } for loss in &losses { println!(" {loss}"); } println!("{} of the offsets tried destroyed the document", losses.len()); pristine(); assert!(losses.is_empty(), "two concurrent saves destroyed the document"); } // ================================================================================================ // Re-verification. Everything below was added to break the four fixes, not to confirm them. // ================================================================================================ /// Every path under `dir` right now, as a sorted list of names. Used by the pollers, so it has to /// be as cheap as a `read_dir` and nothing more. fn names_in(dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(dir) else { return Vec::new(); }; let mut out: Vec = entries .flatten() .map(|e| e.file_name().to_string_lossy().into_owned()) .collect(); out.sort(); out } fn stamp_of(path: &Path) -> Option { let meta = fs::symlink_metadata(path).ok()?; Some(Stamp { kind: if meta.is_dir() { "dir" } else { "file" }, len: meta.len(), mtime: (meta.mtime(), meta.mtime_nsec()), ctime: (meta.ctime(), meta.ctime_nsec()), ino: meta.ino(), mode: meta.mode(), nlink: meta.nlink(), hash: fs::read(path).ok().map(|b| fnv1a(&b)), }) } /// Files a user might plausibly own that share a stem with a document the tests save. The last two /// are the shape the app's own temp file takes, planted deliberately: a save that reasons about /// "its" temp file by name rather than by having created it would unlink these. const PLANTED: [&str; 8] = [ "docs/architecture.md.bak", "docs/architecture.md.tmp", "docs/guides/setup.md.bak", "docs/guides/setup.md.tmp", "docs/guides/mobile.md~", "docs/design.md.bak.bak", "docs/.architecture.md.tmp", "docs/.architecture.md.4242-0-1a2b3c4d.tmp", ]; fn plant(root: &Path) -> BTreeMap { let mut out = BTreeMap::new(); for rel in PLANTED { let path = root.join(rel); fs::write(&path, format!("the user's own file: {rel}\n")).expect("plant"); out.insert(rel.to_string(), stamp_of(&path).expect("a stamp")); } out } // ---------------------------------------------------------------- 12. the user's own siblings /// Claim 1. A save must not touch a `.bak` or `.tmp` sibling the user owns, and must not touch one /// that happens to be shaped exactly like the app's own temp file either. #[test] fn saving_leaves_every_user_owned_sibling_byte_for_byte() { let root = pristine(); // The two the fixture commits, so a deletion is a line of `git status` and not just an absence. let tracked = ["docs/design.md.bak", "docs/conventions.md.tmp"]; let tracked_before: Vec> = tracked.iter().map(|r| stamp_of(&root.join(r))).collect(); let planted_before = plant(&root); let tree = scan_tree(&root, false).expect("a tree"); let mut docs = Vec::new(); collect_editable(&tree, &mut docs); assert!(docs.len() >= 10, "expected a realistic docs folder: {docs:?}"); // Three rounds, because a rotation that keeps one generation only shows up on the second save. for round in 0..3 { for path in &docs { let read = read_document(Path::new(path)).expect("the document"); write_document( Path::new(path), &format!("{}\nround {round}\n", read.text), Some(read.modified_ms), ) .expect("the write"); } } let status = git_status(); println!("git status --porcelain after 3 saves of all {} documents:\n{}", docs.len(), quoted(&status)); let mut damaged = Vec::new(); for (rel, was) in &planted_before { match stamp_of(&root.join(rel)) { None => damaged.push(format!("{rel}: GONE")), Some(now) if &now != was => { damaged.push(format!("{rel}: changed\n was {was:?}\n now {now:?}")) } Some(_) => println!(" untouched: {rel}"), } } for (rel, was) in tracked.iter().zip(tracked_before.iter()) { let now = stamp_of(&root.join(rel)); if &now != was { damaged.push(format!("{rel}: changed\n was {was:?}\n now {now:?}")); } else { println!(" untouched: {rel}"); } } // A deletion of a tracked file is a ` D` line. Nothing but ` M` on documents is allowed. let unexpected: Vec<&str> = status .lines() .filter(|line| !line.starts_with(" M ") && !line.starts_with("?? ")) .collect(); for rel in PLANTED { fs::remove_file(root.join(rel)).ok(); } pristine(); assert!(damaged.is_empty(), "a save touched files the user owns:\n {}", damaged.join("\n ")); assert!( unexpected.is_empty(), "git status shows something other than modified documents: {unexpected:?}" ); } // ---------------------------------------------------------------- 13. what is there mid save /// Claim 2. Poll the folder as hard as a thread can while a save runs, and record every name that /// ever appears. Then say whether what appeared is hidden, unique per save, and gone afterwards. #[test] fn every_path_that_appears_in_the_folder_during_a_save() { let root = pristine(); let dir = root.join("docs"); let target = dir.join("big.md"); // Big enough that the copy and the fsync are wide open to a poller. Untracked; removed below. let payload = "x".repeat(64 * 1024 * 1024); fs::write(&target, &payload).expect("the fixture document"); let quiet = names_in(&dir); println!("docs/ before any save: {quiet:?}"); let mut per_save: Vec> = Vec::new(); for save in 0..3 { let seen = Arc::new(Mutex::new(Vec::::new())); let watching = seen.clone(); let polling = dir.clone(); let baseline = quiet.clone(); let (stop_tx, stop_rx) = mpsc::channel::<()>(); let poller = std::thread::spawn(move || { let mut passes = 0u64; while stop_rx.try_recv().is_err() { for name in names_in(&polling) { if !baseline.contains(&name) { let mut got = watching.lock().unwrap(); if !got.contains(&name) { got.push(name); } } } passes += 1; } passes }); let text = format!("{payload}\nsave {save}\n"); write_document(&target, &text, None).expect("the write"); stop_tx.send(()).ok(); let passes = poller.join().expect("the poller"); let mut got = seen.lock().unwrap().clone(); got.retain(|n| n != "big.md"); println!("save {save}: {passes} polling passes, appeared in docs/: {got:?}"); per_save.push(got); let after = names_in(&dir); let residue: Vec<&String> = after.iter().filter(|n| !quiet.contains(n) && *n != "big.md").collect(); assert!(residue.is_empty(), "save {save} left something in docs/: {residue:?}"); } println!("git status --porcelain after three saves:\n{}", quoted(&git_status())); fs::remove_file(&target).ok(); let all: Vec<&String> = per_save.iter().flatten().collect(); assert!(!all.is_empty(), "the poller never caught the write in flight; the test proves nothing"); for name in &all { assert!(name.starts_with('.'), "the save created a visible file: {name}"); assert!(name.ends_with(".tmp"), "the save created a name the watcher's transient rule misses: {name}"); assert!( name.contains(&format!(".{}", std::process::id())), "the temp name does not carry the pid, so two processes could collide: {name}" ); } let unique: std::collections::BTreeSet<&&String> = all.iter().collect(); assert_eq!(unique.len(), all.len(), "two saves used the same temp name: {all:?}"); pristine(); } /// Claim 2, the failure path. The folder is made unwritable while the save is in flight, so the /// rename cannot land. What is left behind, and is the document still whole? /// /// The clamp is aimed the same way the kill below is: it waits for the save's own temp file to /// appear and takes the write bit off the folder the instant it sees it, so the failure lands /// between the temp file and the rename rather than wherever a sleep happened to fall. #[test] fn what_a_failed_save_leaves_behind() { let root = pristine(); let dir = root.join("docs"); let target = dir.join("big.md"); let payload = "x".repeat(96 * 1024 * 1024); let open = fs::metadata(&dir).expect("docs").permissions(); let mut attempt = 0; let (outcome, before, before_hash, residue) = loop { attempt += 1; fs::set_permissions(&dir, open.clone()).ok(); fs::write(&target, &payload).expect("the fixture document"); let before = fs::read(&target).expect("the document"); let before_hash = fnv1a(&before); let quiet = names_in(&dir); let locking = dir.clone(); let clamp = std::thread::spawn(move || { let deadline = Instant::now() + Duration::from_secs(30); while Instant::now() < deadline { if names_in(&locking).iter().any(|n| !quiet.contains(n)) { break; } } fs::set_permissions(&locking, fs::Permissions::from_mode(0o500)).ok(); quiet }); let outcome = write_document(&target, &format!("{payload}\nthe save that fails\n"), None); let quiet = clamp.join().expect("the clamp"); fs::set_permissions(&dir, open.clone()).ok(); let residue: Vec = names_in(&dir) .into_iter() .filter(|n| !quiet.contains(n) && n != "big.md") .collect(); if outcome.is_err() || attempt >= 8 { break (outcome, before.len(), before_hash, residue); } println!("attempt {attempt}: the save beat the clamp, trying again"); for name in &residue { fs::remove_file(dir.join(name)).ok(); } }; let after = fs::read(&target).ok(); println!("the save returned: {outcome:?}"); println!("the document is still there: {}", after.is_some()); println!( "its bytes: {} -> {}, hash {} -> {}", before, after.as_ref().map(|b| b.len()).unwrap_or(0), before_hash, after.as_ref().map(|b| fnv1a(b)).unwrap_or(0) ); println!("left in docs/ after the failure: {residue:?}"); println!("git status --porcelain:\n{}", quoted(&git_status())); for name in &residue { fs::remove_file(dir.join(name)).ok(); } fs::remove_file(&target).ok(); assert!(outcome.is_err(), "the clamp never managed to make a save fail: {outcome:?}"); let after = after.expect("the document must survive a failed save"); assert_eq!(after.len(), before, "a failed save truncated the document"); assert_eq!(fnv1a(&after), before_hash, "a failed save changed the document"); for name in &residue { assert!(name.starts_with('.') && name.ends_with(".tmp"), "a failed save left something that is not a hidden temp file: {name}"); } pristine(); } // ---------------------------------------------------------------- 14. killed mid write /// Re-exec of this same binary, used as the victim of the SIGKILL below. A no-op when the /// environment does not ask for it, which is every normal run of the suite. #[test] fn the_child_writer() { let Ok(target) = std::env::var("MARGIN_KILL_TARGET") else { return; }; let megabytes: usize = std::env::var("MARGIN_KILL_MB") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(512); let text = format!("the interrupted save\n{}\n", "q".repeat(megabytes * 1024 * 1024)); let _ = write_document(Path::new(&target), &text, None); println!("the child finished its write, which the parent did not want"); } /// Claim 3. SIGKILL in the middle of a real save of a real document. The document must be whole /// afterwards, at the old bytes or the new ones and never in between. /// /// The kill is aimed rather than timed: the parent watches the folder for the save's own temp file /// to appear, which is proof the write is under way, and fires a fixed number of microseconds after /// that. A round where the child finished first is not a round, and the test says so. #[test] fn killing_the_process_mid_write_leaves_the_document_whole() { let root = pristine(); let dir = root.join("docs"); let target = dir.join("architecture.md"); let before = fs::read(&target).expect("the document"); let quiet = names_in(&dir); const MB: usize = 512; let mut orphans: Vec = Vec::new(); let mut survived = 0; let mut killed = 0; let mut missed = 0; for after_us in [0u64, 200, 1_000, 5_000, 20_000, 80_000, 250_000, 600_000] { let mut child = Command::new(std::env::current_exe().expect("this binary")) .args(["--exact", "the_child_writer", "--nocapture"]) .env("MARGIN_KILL_TARGET", &target) .env("MARGIN_KILL_MB", MB.to_string()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .expect("the child"); // Wait for the write to actually be in flight: its temp file is the only signal, and it is // created before a single byte of the new content is written. let deadline = Instant::now() + Duration::from_secs(30); let mut mid: Vec = Vec::new(); while Instant::now() < deadline { mid = names_in(&dir) .into_iter() .filter(|n| !quiet.contains(n)) .collect(); if !mid.is_empty() { break; } if child.try_wait().ok().flatten().is_some() { break; } } std::thread::sleep(Duration::from_micros(after_us)); let raced = child.try_wait().ok().flatten().is_some(); child.kill().ok(); let status = child.wait().expect("the child exits"); if raced { missed += 1; } else { killed += 1; } let now = fs::read(&target).expect("the document must still be there"); let new_whole = now.starts_with(b"the interrupted save\n") && now.len() == MB * 1024 * 1024 + 22; let whole = now == before || new_whole; if whole && !raced { survived += 1; } println!( "kill {after_us}us after the temp file appeared: child {status:?}{}, \ saw {mid:?} in flight, document {} bytes, {}", if raced { " (finished first, not a kill)" } else { "" }, now.len(), if now == before { "the old content" } else if new_whole { "the new content" } else { "TORN" } ); assert!(whole, "the document is neither the old content nor the new one"); let left: Vec = names_in(&dir) .into_iter() .filter(|n| !quiet.contains(n)) .collect(); for name in &left { if !orphans.contains(name) { orphans.push(name.clone()); } fs::remove_file(dir.join(name)).ok(); } // Back to the old bytes for the next round. fs::write(&target, &before).expect("reset"); } assert!(killed >= 5, "only {killed} of the rounds actually caught a write in flight"); println!("{survived}/{killed} real kills left the document whole ({missed} rounds the child won)"); println!("orphans left by a killed save: {orphans:?}"); println!("git status --porcelain (orphans already swept by this test):\n{}", quoted(&git_status())); assert_eq!(survived, killed, "a kill mid-write left the document neither old nor new"); for name in &orphans { assert!( name.starts_with('.') && name.ends_with(".tmp"), "a killed save left a visible file in the user's folder: {name}" ); } pristine(); } // ---------------------------------------------------------------- 15. concurrency, harder /// Claim 4. 2, 4, 8 and 16 writers against one document, 20 trials each. The document must come out /// as exactly one writer's bytes: never missing, never truncated, never a mixture. #[test] fn concurrent_writers_two_four_eight_sixteen() { const TRIALS: usize = 20; let mut broken: Vec = Vec::new(); for writers in [2usize, 4, 8, 16] { let mut lost = 0; let mut torn = 0; let mut short = 0; let mut errors: Vec = Vec::new(); let mut leftovers: Vec = Vec::new(); let mut sizes: std::collections::BTreeSet = std::collections::BTreeSet::new(); let mut bad_status = String::new(); for trial in 0..TRIALS { let root = pristine(); let doc = root.join("docs/guides/setup.md"); let seen = read_document(&doc).expect("the document").modified_ms; // Wildly different lengths, so a torn result cannot pass for a whole one by luck: a // truncation of the 4 MiB writer is not equal to any other writer's payload. let payloads: Vec = (0..writers) .map(|n| { let size = 64 * 1024 * (1 << (n % 7)); format!("save number {n}\n{}\nend of save number {n}\n", "z".repeat(size)) }) .collect(); let gate = Arc::new(std::sync::Barrier::new(writers)); let handles: Vec<_> = payloads .iter() .cloned() .map(|text| { let doc = doc.clone(); let gate = gate.clone(); std::thread::spawn(move || { gate.wait(); write_document(&doc, &text, Some(seen)).err() }) }) .collect(); for handle in handles { if let Some(e) = handle.join().expect("a thread") { errors.push(e); } } match fs::read_to_string(&doc).ok() { None => { lost += 1; if bad_status.is_empty() { bad_status = git_status(); } } Some(text) if !payloads.contains(&text) => { if payloads.iter().any(|p| p.starts_with(&text) || text.starts_with(p)) { short += 1; } else { torn += 1; } if bad_status.is_empty() { bad_status = git_status(); } println!(" trial {trial}: {} bytes, matching no writer", text.len()); } Some(text) => { sizes.insert(text.len()); } } for path in snapshot(&root).keys() { if path.starts_with(".git/") { continue; } let owned = path == "docs/conventions.md.tmp" || path == "docs/design.md.bak"; let sidecar = path.ends_with(".bak") || path.ends_with(".tmp") || path.contains("/."); if sidecar && !owned && !leftovers.contains(path) { leftovers.push(path.clone()); } } } println!( "{writers} writers x {TRIALS} trials: {lost} destroyed, {torn} torn, {short} truncated, \ {} errors, winners had sizes {:?}, leftovers {leftovers:?}", errors.len(), sizes ); if !errors.is_empty() { println!(" first error: {}", errors[0]); } if !bad_status.is_empty() { println!(" git status --porcelain from a bad trial:\n{}", quoted(&bad_status)); } if lost > 0 || torn > 0 || short > 0 || !leftovers.is_empty() || !errors.is_empty() { broken.push(format!( "{writers} writers: {lost} destroyed, {torn} torn, {short} truncated, \ {} errors, leftovers {leftovers:?}", errors.len() )); } } pristine(); assert!( broken.is_empty(), "concurrent saves of one document are not safe:\n {}", broken.join("\n ") ); } // ---------------------------------------------------------------- 16. the watcher stays quiet /// Claim 5. A save emits nothing to the frontend, for the document or for the temp file, and the /// watcher is demonstrably alive while it does so: the same test makes a change the app did not /// make and requires that one through. #[test] fn a_save_emits_no_watch_event_but_an_outside_change_does() { let root = pristine(); let (tx, rx) = mpsc::channel::(); let watcher = spawn_watcher( "root-1".to_string(), root.to_string_lossy().into_owned(), move |events| { for event in events { tx.send(event).ok(); } }, ) .expect("a watcher"); std::thread::sleep(Duration::from_secs(2)); while rx.try_recv().is_ok() {} // Every shape of write the app performs: an existing document, a run of quick saves, a brand // new file, a document in a nested folder, and one big enough that the copy and the fsync take // real time. let existing = root.join("docs/design.md"); let read = read_document(&existing).expect("the document"); write_document(&existing, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) .expect("the write"); for n in 0..8 { let read = read_document(&existing).expect("the document"); write_document(&existing, &format!("{}\nautosave {n}\n", read.text), Some(read.modified_ms)) .expect("the write"); } let nested = root.join("docs/internals/website.md"); let read = read_document(&nested).expect("the document"); write_document(&nested, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) .expect("the write"); let fresh = root.join("docs/guides/brand-new.md"); write_document(&fresh, "# new\n", None).expect("the write"); let big = root.join("docs/heavy.md"); write_document(&big, &"h".repeat(48 * 1024 * 1024), None).expect("the write"); // At the root of the watched folder, where neither the hidden rule nor the transient rule can // help: only `note_self_write` stands between this save and the frontend. let top = root.join("README.md"); let read = read_document(&top).expect("the document"); write_document(&top, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) .expect("the write"); // Spelled through the /tmp symlink, which is how a path picked in a file dialog can reach the // backend while the watcher is placed on the canonical /private/tmp form. let aliased = PathBuf::from( root.to_string_lossy() .replacen("/private/tmp/", "/tmp/", 1), ) .join("notes.txt"); let read = read_document(&aliased).expect("the document"); write_document(&aliased, &format!("{}\nedited\n", read.text), Some(read.modified_ms)) .expect("the write"); std::thread::sleep(Duration::from_secs(3)); let ours: Vec = rx.try_iter().map(|e| format!("{} {}", e.kind, e.path)).collect(); println!("watch events for {} saves the app made:", 14); for event in &ours { println!(" {event}"); } // Now a change the app did not make, written straight to disk with no suppression, to prove the // silence above is suppression and not a dead watcher. let outside = root.join("docs/guides/release.md"); let text = fs::read_to_string(&outside).expect("the document"); fs::write(&outside, format!("{text}\nsomebody else edited this\n")).expect("the outside write"); std::thread::sleep(Duration::from_secs(3)); let theirs: Vec = rx.try_iter().map(|e| format!("{} {}", e.kind, e.path)).collect(); println!("watch events for one change the app did not make:"); for event in &theirs { println!(" {event}"); } drop(watcher); println!("git status --porcelain:\n{}", quoted(&git_status())); fs::remove_file(&fresh).ok(); fs::remove_file(&big).ok(); pristine(); assert!(ours.is_empty(), "the app's own saves reached the frontend as external changes: {ours:?}"); assert!( theirs.iter().any(|e| e.ends_with("docs/guides/release.md")), "the watcher missed a change the app did not make, so the silence above proves nothing: {theirs:?}" ); } /// Claim 5, the wiring. `note_self_write` is called from production code, for both ends of the /// rename, and not only from tests. #[test] fn note_self_write_is_called_from_production_code() { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut callers: Vec = Vec::new(); let mut stack = vec![src.clone()]; while let Some(dir) = stack.pop() { for entry in fs::read_dir(&dir).expect("src").flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); continue; } if path.extension().and_then(|e| e.to_str()) != Some("rs") { continue; } let text = fs::read_to_string(&path).expect("a source file"); for (n, line) in text.lines().enumerate() { let trimmed = line.trim(); if trimmed.starts_with("//") || trimmed.starts_with("///") { continue; } if trimmed.contains("note_self_write(") && !trimmed.starts_with("pub fn") { callers.push(format!( "{}:{}: {trimmed}", path.strip_prefix(&src).unwrap_or(&path).display(), n + 1 )); } } } } println!("callers of note_self_write outside tests:"); for caller in &callers { println!(" {caller}"); } assert!( !callers.is_empty(), "note_self_write is dead code: nothing in src/ calls it" ); assert!( callers.iter().any(|c| c.starts_with("fs.rs")), "nothing in the write path calls note_self_write: {callers:?}" ); // Both ends of the rename: the target and the temp file. assert!( callers.iter().any(|c| c.contains("(&tmp)")), "the temp file is not registered as a self write: {callers:?}" ); assert!( callers.iter().any(|c| c.contains("(path)")), "the document is not registered as a self write: {callers:?}" ); } /// Claim 2 again, on the failure path the app can actually clean up after: the folder stays /// writable and the copy of the original is what fails. Nothing at all may be left behind. #[test] fn a_save_that_fails_where_it_can_tidy_up_leaves_nothing() { let root = pristine(); let dir = root.join("docs"); let target = dir.join("architecture.md"); let before = fs::read(&target).expect("the document"); let quiet = names_in(&dir); let open = fs::metadata(&target).expect("the document").permissions(); fs::set_permissions(&target, fs::Permissions::from_mode(0o000)).expect("lock the document"); let outcome = write_document(&target, "the save that cannot read the original\n", None); fs::set_permissions(&target, open).expect("unlock the document"); let residue: Vec = names_in(&dir) .into_iter() .filter(|n| !quiet.contains(n)) .collect(); println!("the save returned: {outcome:?}"); println!("left in docs/: {residue:?}"); println!("git status --porcelain:\n{}", quoted(&git_status())); assert!(outcome.is_err(), "a save over an unreadable document reported success"); assert!(residue.is_empty(), "a recoverable failure left something behind: {residue:?}"); assert_eq!(fs::read(&target).expect("the document"), before, "the document changed"); assert!(git_status().is_empty(), "a failed save left the repo dirty"); pristine(); }