From 72b74b7512f82f28ce288ba46c1280029891c5ce Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 11 Oct 2026 13:01:22 +0200 Subject: [PATCH] perf: skip re-reading files whose size and mtime are unchanged Since every compilation resets the file slots, each file a document touches was read and hashed in full on every compile, packages and plugins included. A slot now keeps the path, length and modification time of the file it was read from, and a reset slot whose stamp still matches a fresh stat is served from cache without a read. The stat is taken once per file and compilation and shared by the source and bytes views of the file, and it doubles as the stamp when the file is read. A stamp is only trusted once the file is at least two seconds older than the read that took it, so a rewrite within one tick of a coarse file system clock is still picked up (git's racily-clean check). --- README.md | 15 +- RELEASENOTES.md | 2 +- src/typst_core/src/world.rs | 517 ++++++++++++++++++++++++++++++++++-- 3 files changed, 500 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 5e55720..2cd8d47 100644 --- a/README.md +++ b/README.md @@ -212,12 +212,15 @@ builds reproducible: whatever is deployed is exactly what gets compiled. You can easily use this inside of an ASP.Net Server (just ensure you lazy load and cache the TypstCompiler to reduce from 40ms to around 3ms for a normal compile). -A cached compiler stays current: every compilation reads the template, its imports and its data -files from disk again, so redeploying a template takes effect without restarting the process. Two -things follow from that. Replace template files atomically — write a temporary file and rename it — -because a compilation that lands halfway through a plain overwrite renders the half-written file. -And a warm compilation now costs one stat and one read per file it touches, so a template split -across many files is measurably more expensive to recompile than a single one. +A cached compiler stays current: every compilation checks the template, its imports and its data +files on disk, so redeploying a template takes effect without restarting the process. The check +is one stat per file the compilation touches, and a file is read again only when its size or +modification time has changed. Two things follow from that. Replace template files atomically, +by writing a temporary file and renaming it, because a compilation that lands halfway through a +plain overwrite renders the half-written file. And a deployment that rewrites a file while keeping +both its size and its modification time, such as a copy that preserves timestamps over a file of +the same length, is not noticed; a file modified within the last two seconds is always read, so +an ordinary write is safe even on file systems with coarse timestamps. ## Prerequisites diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 294cdb1..326f53c 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Fixed - Fixed a stream from `document.OpenOutputStream()` leaving the rendered document in `ArrayPool.Shared`. Only the byte-array reads were overridden, so span-shaped and asynchronous reads, and `CopyTo`, fell through to `Stream`, which stages the bytes through a rented array and returns it to the pool without clearing it. The next component in the process to rent from that pool could read the document back. The reads and the copy now go straight from native memory, which also removes a second copy of every byte: a 4 MiB span read measured 0.767 ms before and 0.274 ms after. -- Fixed a compiler reused across compilations never seeing a change made to the files it reads. The main file and every import were read on first access and then kept forever, so a cached `TypstCompiler` — which the README recommends, because it takes a compile from around 40ms down to 3ms — went on rendering the template it had read at startup, with no error to show for it. Every compilation now starts by invalidating the file slots, the way `typst-cli` does between watch runs, so a template redeployed underneath a running process takes effect. Files whose content has not changed are recognised by their hash and are not parsed again, and a document passed in as a string is unaffected: there is no file behind it. Two consequences worth knowing about: templates should be replaced atomically, because a compilation that lands halfway through a plain overwrite now renders the half-written file; and a warm compilation costs one stat and one read per file it touches, so a template split across many files is measurably more expensive to recompile than a single one. +- Fixed a compiler reused across compilations never seeing a change made to the files it reads. The main file and every import were read on first access and then kept forever, so a cached `TypstCompiler`, which the README recommends because it takes a compile from around 40ms down to 3ms, went on rendering the template it had read at startup, with no error to show for it. Every compilation now starts by invalidating the file slots, the way `typst-cli` does between watch runs, so a template redeployed underneath a running process takes effect. A file whose size and modification time are unchanged is not read again, so a warm compilation costs one stat per file it touches; a file that is read but hashes the same is not parsed again; and a document passed in as a string is unaffected: there is no file behind it. Two consequences worth knowing about: templates should be replaced atomically, because a compilation that lands halfway through a plain overwrite now renders the half-written file; and a rewrite that keeps both the size and the modification time of a file, such as a timestamp-preserving copy over a file of the same length, is not picked up. A file modified within the last two seconds is always read, which covers rewrites within one tick of a coarse file system clock. - Fixed an input path in a subfolder aborting the process instead of compiling. The path was handed to Typst verbatim, and a Typst virtual path only accepts forward slashes, so on Windows an ordinary relative path such as `templates\letter.typ` panicked inside a native call and took the host process down with it. Paths are now resolved against the project root before being converted, and a path that genuinely leaves the root reports an error instead of panicking. Note that the root is matched against an absolute input path textually, so on Windows both have to be spelled with the same casing. - Fixed `ua-1` (PDF/UA-1, the accessibility standard) being rejected by `pdfStandards`. The accepted names are now taken from `typst_pdf::PdfStandard` itself, so every standard Typst supports is accepted, including ones added by later Typst releases. The documented `v-` prefix on plain PDF versions still works, and applies only to them: `v-a-2b` is not a spelling of `a-2b`. diff --git a/src/typst_core/src/world.rs b/src/typst_core/src/world.rs index f1c156e..647b403 100644 --- a/src/typst_core/src/world.rs +++ b/src/typst_core/src/world.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use std::time::SystemTime; use ecow::eco_format; use typst::diag::{FileError, FileResult, StrResult}; @@ -175,11 +176,14 @@ impl SystemWorld { /// Prepares the world for a new compilation. /// /// Drops the cached date/time and marks every file slot as not yet accessed, so - /// the next access reads the file from disk again. A compiler is meant to be kept + /// the next access checks the file on disk again. A compiler is meant to be kept /// alive across compilations for its incremental cache, and one that held on to /// the content each file had when it was first read would go on rendering a /// template that has since been rewritten, and give no sign of it. /// + /// The check is one stat per file the compilation touches: a file is read again + /// only when its size or modification time has changed since it was last read. + /// /// Slots holding a document that was handed over in memory keep it: there is no /// file behind them to read. /// @@ -207,6 +211,9 @@ struct FileSlot { id: FileId, source: SlotCell, file: SlotCell, + /// The file's stat for the current compilation, shared by both views so that a + /// file used both as source and as bytes is stat'ed once. + probe: Probe, } impl FileSlot { @@ -215,6 +222,7 @@ impl FileSlot { id, file: SlotCell::new(), source: SlotCell::new(), + probe: Probe::default(), } } @@ -226,6 +234,7 @@ impl FileSlot { let id = self.id; self.source.get_or_init( || system_path(project_root, id, packages), + &mut self.probe, |data, prev| { let text = decode_utf8(&data)?; if let Some(mut prev) = prev { @@ -242,21 +251,26 @@ impl FileSlot { let id = self.id; self.file.get_or_init( || system_path(project_root, id, packages), + &mut self.probe, |data, _| Ok(Bytes::new(data)), ) } - /// Sends both views of the file back to disk for the next compilation. + /// Sends both views of the file back to disk for the next compilation, where each + /// is checked against the file's size and modification time and read again only + /// if those have changed. /// - /// Package files are included. A package addressed with a fixed version cannot - /// legitimately change under it, but a deployment that vendors its templates as - /// local packages redeploys them exactly the way it redeploys a bare `.typ`, and - /// that is the case this exists for. A package that failed to resolve is looked up - /// again for the same reason: one that is vendored afterwards starts working, - /// rather than staying broken for the life of the compiler. + /// Package files are included, and the stamp check keeps them cheap. A package + /// addressed with a fixed version cannot legitimately change under it, but a + /// deployment that vendors its templates as local packages redeploys them exactly + /// the way it redeploys a bare `.typ`, and that is the case this exists for. A + /// package that failed to resolve is looked up again for the same reason: one that + /// is vendored afterwards starts working, rather than staying broken for the life + /// of the compiler. fn reset(&mut self) { self.source.reset(); self.file.reset(); + self.probe = Probe::default(); } } @@ -277,6 +291,9 @@ fn system_path( struct SlotCell { data: Option>, fingerprint: u128, + /// The file as it was when `data` was read from it, if that is trustworthy enough + /// to skip reading it again. `None` sends the next access to the file. + stamp: Option, accessed: bool, /// Whether the value was handed over rather than read from a file. Such a cell /// has no path behind it, so it is the one thing a reset must not invalidate. @@ -288,6 +305,7 @@ impl SlotCell { Self { data: None, fingerprint: 0, + stamp: None, accessed: false, in_memory: false, } @@ -307,18 +325,25 @@ impl SlotCell { /// Sends the cell back to the file for the next compilation, unless it holds a /// value that was handed over rather than read. /// - /// The cached value and its fingerprint are kept either way: if the file turns - /// out to be unchanged, the value is handed out again instead of being decoded a - /// second time, which is what keeps recompiling an unchanged document cheap. + /// The cached value, its fingerprint and its stamp are kept either way. A file + /// whose size and modification time still match the stamp is not read at all, + /// and one that is read but hashes the same is not decoded a second time: that + /// is what keeps recompiling an unchanged document cheap. fn reset(&mut self) { if !self.in_memory { self.accessed = false; } } + /// Hands out the cell's value, going to the file first if the cell was reset. + /// + /// `probe` holds the stat of the file for the current compilation. It is shared + /// with the slot's other view of the same file and has to be cleared between + /// compilations, or a change on disk goes unnoticed. fn get_or_init( &mut self, path: impl FnOnce() -> FileResult, + probe: &mut Probe, f: impl FnOnce(Vec, Option) -> FileResult, ) -> FileResult { if std::mem::replace(&mut self.accessed, true) { @@ -327,7 +352,24 @@ impl SlotCell { } } - let result = path().and_then(|p| read(&p)); + let (result, stamp) = match path() { + Ok(path) => { + let stat = probe.stat(&path); + + // An unchanged stamp stands in for reading and hashing the file: one + // stat instead of a read, which matters for a large file such as a + // plugin. + if let (Some(data), Some(stamp)) = (&self.data, &self.stamp) { + if stamp.path == path && stat == Some(stamp.stat) { + return data.clone(); + } + } + + read(path, stat) + } + Err(err) => (Err(err), None), + }; + self.stamp = stamp; let fingerprint = typst::utils::hash128(&result); if std::mem::replace(&mut self.fingerprint, fingerprint) == fingerprint { @@ -344,7 +386,97 @@ impl SlotCell { } } -fn read(path: &Path) -> FileResult> { +/// How long after its last modification a file's stamp is still distrusted. +/// +/// A file rewritten within the timestamp granularity of its previous write can end up +/// with the same size and the same modification time, and a stamp taken in that window +/// would hide the second write. Two seconds covers the coarsest common case, FAT and +/// some SMB shares; NTFS and ext4 are far finer. This is git's "racily clean" check. +const RACY_WINDOW: std::time::Duration = std::time::Duration::from_secs(2); + +/// What a stat says about a file: enough to tell, without reading it, that it has not +/// changed since an earlier stat. +#[derive(Clone, Copy, PartialEq, Eq)] +struct FileStat { + len: u64, + modified: SystemTime, +} + +impl FileStat { + /// Stats a file, or returns `None` if there is nothing a stat could vouch for: + /// the stat failed, the path is a directory, or the platform has no modification + /// time. + fn of(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + if metadata.is_dir() { + return None; + } + Some(Self { + len: metadata.len(), + modified: metadata.modified().ok()?, + }) + } + + /// Whether the file was last modified long enough before `now` that a later + /// write is guaranteed to move its modification time. A time in the future, from a + /// skewed clock or a copied file, is never settled. + fn is_settled(&self, now: SystemTime) -> bool { + now.duration_since(self.modified).is_ok_and(|age| age >= RACY_WINDOW) + } +} + +/// The stat of a file taken just before it was last read. +/// +/// The path is part of the stamp because a package file's path is resolved anew on +/// every access, and a package vendored after the first read moves it. +struct FileStamp { + path: PathBuf, + stat: FileStat, +} + +/// The stat of a file within one compilation, taken on the first access and reused by +/// every later one. +/// +/// Within a compilation each view of a file is pinned to what it saw first anyway, so +/// one stat per file is all the freshness a compilation needs. +#[derive(Default)] +struct Probe { + taken: Option<(PathBuf, Option)>, +} + +impl Probe { + /// Stats `path`, or returns the stat already taken of it in this compilation. + fn stat(&mut self, path: &Path) -> Option { + match &self.taken { + Some((taken, stat)) if taken == path => *stat, + _ => { + let stat = FileStat::of(path); + self.taken = Some((path.to_path_buf(), stat)); + stat + } + } + } +} + +/// Reads a file, and returns with it the stamp that lets the next access skip the +/// read if the file is unchanged. There is no stamp for a failed read, so a file that +/// could not be read is tried again, nor for a file modified too recently to be +/// trusted. +/// +/// `stat` was taken before the read, so a write landing between the two leaves a +/// stamp older than the content and the next access reads the file again. Without a +/// stat the file is read the plain way, and a fresh stat says why it could not be. +fn read(path: PathBuf, stat: Option) -> (FileResult>, Option) { + let Some(stat) = stat else { + return (read_unstamped(&path), None); + }; + + let result = fs::read(&path).map_err(|e| FileError::from_io(e, &path)); + let settled = result.is_ok() && stat.is_settled(SystemTime::now()); + (result, settled.then_some(FileStamp { path, stat })) +} + +fn read_unstamped(path: &Path) -> FileResult> { let f = |e| FileError::from_io(e, path); if fs::metadata(path).map_err(f)?.is_dir() { Err(FileError::IsDirectory) @@ -401,6 +533,49 @@ mod tests { } } + /// Gives a file the modification time a deployed template would have, long past the + /// racy window, or puts back one that a rewrite moved. + fn set_modified(path: &Path, time: SystemTime) { + std::fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(time) + .unwrap(); + } + + fn an_hour_ago() -> SystemTime { + SystemTime::now() - std::time::Duration::from_secs(3600) + } + + /// Reads a cell through `path` as the only access to the file in a compilation, + /// counting how often it decodes. + fn get( + cell: &mut SlotCell, + path: &Path, + decodes: &Cell, + ) -> FileResult { + get_with(cell, path, &mut Probe::default(), decodes) + } + + /// Reads a cell through `path` with the stat other accesses in the same compilation + /// share, counting how often it decodes. + fn get_with( + cell: &mut SlotCell, + path: &Path, + probe: &mut Probe, + decodes: &Cell, + ) -> FileResult { + cell.get_or_init( + || Ok(path.to_path_buf()), + probe, + |data, _| { + decodes.set(decodes.get() + 1); + Ok(String::from_utf8(data).unwrap()) + }, + ) + } + /// A cell holds its value until it is reset, and then reports whatever the file /// says. Decoding is the expensive half of a read, so it has to happen only when /// the content has actually changed: the fingerprint is what tells the two apart. @@ -409,27 +584,23 @@ mod tests { let dir = TempDir::new("re-read"); let path = dir.write("note.txt", "first"); - let decodes = Cell::new(0usize); - let decode = |data: Vec, _: Option| { - decodes.set(decodes.get() + 1); - Ok(String::from_utf8(data).unwrap()) - }; - let mut cell = SlotCell::::new(); + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); - assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "first"); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); assert_eq!(decodes.get(), 1); // Within one compilation the file is read once, however often it is asked for. std::fs::write(&path, "second").unwrap(); - assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "first"); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); assert_eq!(decodes.get(), 1); cell.reset(); - assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "second"); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "second"); assert_eq!(decodes.get(), 2); cell.reset(); - assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "second"); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "second"); assert_eq!(decodes.get(), 2, "an unchanged file was decoded a second time"); } @@ -440,15 +611,15 @@ mod tests { let dir = TempDir::new("appearing"); let path = dir.path.join("late.txt"); - let decode = |data: Vec, _: Option| Ok(String::from_utf8(data).unwrap()); - let mut cell = SlotCell::::new(); + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); - assert!(cell.get_or_init(|| Ok(path.clone()), decode).is_err()); + assert!(get(&mut cell, &path, &decodes).is_err()); std::fs::write(&path, "here now").unwrap(); cell.reset(); - assert_eq!(cell.get_or_init(|| Ok(path.clone()), decode).unwrap(), "here now"); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "here now"); } /// A value that was handed over has no file behind it, so a reset must leave it @@ -462,8 +633,300 @@ mod tests { let value = cell.get_or_init( || panic!("an in-memory cell must not be resolved to a path"), + &mut Probe::default(), |_, _| unreachable!("an in-memory cell must not be decoded"), ); assert_eq!(value.unwrap(), "handed over"); } + + /// An unchanged size and modification time stand in for reading the file. The proof + /// is a rewrite that keeps both: only a cell that skipped the read still has the + /// old content. + #[test] + fn a_reset_cell_skips_reading_a_file_whose_stamp_is_unchanged() { + let dir = TempDir::new("stamp-unchanged"); + let path = dir.write("note.txt", "first"); + let deployed = an_hour_ago(); + set_modified(&path, deployed); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + + std::fs::write(&path, "fresh").unwrap(); + set_modified(&path, deployed); + cell.reset(); + + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + assert_eq!(decodes.get(), 1); + } + + /// A rewrite that keeps the length but moves the modification time is read. + #[test] + fn a_reset_cell_re_reads_a_file_whose_modification_time_changed() { + let dir = TempDir::new("stamp-mtime"); + let path = dir.write("note.txt", "first"); + let deployed = an_hour_ago(); + set_modified(&path, deployed); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + + std::fs::write(&path, "fresh").unwrap(); + set_modified(&path, deployed + std::time::Duration::from_secs(60)); + cell.reset(); + + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "fresh"); + } + + /// A rewrite that keeps the modification time but changes the length is read. + #[test] + fn a_reset_cell_re_reads_a_file_whose_length_changed() { + let dir = TempDir::new("stamp-len"); + let path = dir.write("note.txt", "first"); + let deployed = an_hour_ago(); + set_modified(&path, deployed); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + + std::fs::write(&path, "a longer second version").unwrap(); + set_modified(&path, deployed); + cell.reset(); + + assert_eq!( + get(&mut cell, &path, &decodes).unwrap(), + "a longer second version" + ); + } + + /// A file read moments after it was written may be written again within the same + /// timestamp tick, leaving size and modification time as they were. Its stamp is not + /// trusted, so the rewrite below, which forges exactly that, is still picked up. + #[test] + fn a_file_written_just_now_is_read_again_even_with_an_unchanged_stamp() { + let dir = TempDir::new("stamp-racy"); + let path = dir.write("note.txt", "first"); + let written = std::fs::metadata(&path).unwrap().modified().unwrap(); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + + std::fs::write(&path, "fresh").unwrap(); + set_modified(&path, written); + cell.reset(); + + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "fresh"); + } + + /// A distrusted stamp is judged again on the read it forces, so a file stops being + /// read on every compilation once it has settled. + #[test] + fn a_distrusted_stamp_is_trusted_once_the_file_has_settled() { + let dir = TempDir::new("stamp-settles"); + let path = dir.write("note.txt", "first"); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + + // Stands in for the clock moving past the racy window. + let deployed = an_hour_ago(); + set_modified(&path, deployed); + cell.reset(); + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + + std::fs::write(&path, "fresh").unwrap(); + set_modified(&path, deployed); + cell.reset(); + + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + assert_eq!(decodes.get(), 1, "an unchanged file was decoded a second time"); + } + + /// A package file's path is resolved on every access and moves when the package is + /// vendored somewhere else. Two files with the same size and modification time are + /// still two files. + #[test] + fn a_stamp_does_not_vouch_for_a_file_at_another_path() { + let dir = TempDir::new("stamp-path"); + let cached = dir.write("cache.typ", "first"); + let vendored = dir.write("vendor.typ", "fresh"); + let deployed = an_hour_ago(); + set_modified(&cached, deployed); + set_modified(&vendored, deployed); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + assert_eq!(get(&mut cell, &cached, &decodes).unwrap(), "first"); + + cell.reset(); + + assert_eq!(get(&mut cell, &vendored, &decodes).unwrap(), "fresh"); + } + + /// Both views of a file share one stat per compilation, so the second is checked + /// against the stat the first one took. The file is rewritten between the two + /// accesses with a new length: a second stat would have sent the second view back + /// to the file. + #[test] + fn both_views_of_a_file_share_one_stat_per_compilation() { + let dir = TempDir::new("stamp-shared"); + let path = dir.write("note.typ", "first"); + set_modified(&path, an_hour_ago()); + + let decodes = Cell::new(0); + let (mut source, mut bytes) = (SlotCell::new(), SlotCell::new()); + assert_eq!(get(&mut source, &path, &decodes).unwrap(), "first"); + assert_eq!(get(&mut bytes, &path, &decodes).unwrap(), "first"); + + source.reset(); + bytes.reset(); + let mut probe = Probe::default(); + assert_eq!(get_with(&mut source, &path, &mut probe, &decodes).unwrap(), "first"); + std::fs::write(&path, "a longer second version").unwrap(); + assert_eq!(get_with(&mut bytes, &path, &mut probe, &decodes).unwrap(), "first"); + + // The next compilation starts with a fresh stat and sees the rewrite. + bytes.reset(); + assert_eq!( + get(&mut bytes, &path, &decodes).unwrap(), + "a longer second version" + ); + } + + /// The stat shared within a compilation must not outlive it. The file is dated in + /// the past so that only the stat, not the racy guard, decides whether the rewrite + /// is seen. + #[test] + fn a_reset_world_sees_a_rewrite_of_a_settled_file() { + let dir = TempDir::new("world-reset"); + let path = dir.write("main.typ", "first"); + set_modified(&path, an_hour_ago()); + + let mut world = SystemWorld::new( + dir.path.clone(), + &[], + None, + typst::foundations::Dict::new(), + Some(PathBuf::from("main.typ")), + None, + false, + false, + ) + .unwrap(); + let main = world.main(); + assert_eq!(world.source(main).unwrap().text(), "first"); + + std::fs::write(&path, "a longer second version").unwrap(); + world.reset(); + + assert_eq!( + world.source(main).unwrap().text(), + "a longer second version" + ); + } + + /// A file that can be stat'ed but not read keeps no stamp. Otherwise the error would + /// be served from cache until the file's size or modification time happened to + /// change, and neither does when the reader that held it releases it. + #[test] + fn a_file_that_could_not_be_read_is_read_again() { + let dir = TempDir::new("stamp-unreadable"); + let path = dir.write("note.txt", "first"); + set_modified(&path, an_hour_ago()); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + { + let _unreadable = Unreadable::new(&path); + if std::fs::read(&path).is_ok() { + // Running as root on Unix, where permissions do not stop a read. + return; + } + assert!(get(&mut cell, &path, &decodes).is_err()); + } + cell.reset(); + + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "first"); + } + + /// Keeps a file stat-able but unreadable while alive, without touching its size or + /// modification time: an exclusive handle on Windows, no permissions on Unix. + struct Unreadable { + #[cfg(windows)] + _handle: std::fs::File, + #[cfg(unix)] + path: PathBuf, + } + + impl Unreadable { + #[cfg(windows)] + fn new(path: &Path) -> Self { + use std::os::windows::fs::OpenOptionsExt; + let handle = std::fs::File::options() + .read(true) + .share_mode(0) + .open(path) + .unwrap(); + Self { _handle: handle } + } + + #[cfg(unix)] + fn new(path: &Path) -> Self { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000)).unwrap(); + Self { + path: path.to_path_buf(), + } + } + } + + #[cfg(unix)] + impl Drop for Unreadable { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o644)); + } + } + + /// A directory has nothing a stamp could vouch for, so it keeps being reported as a + /// directory, and a file put in its place is picked up. + #[test] + fn a_directory_is_reported_until_a_file_replaces_it() { + let dir = TempDir::new("stamp-dir"); + let path = dir.path.join("entry"); + std::fs::create_dir(&path).unwrap(); + + let decodes = Cell::new(0); + let mut cell = SlotCell::new(); + assert!(matches!(get(&mut cell, &path, &decodes), Err(FileError::IsDirectory))); + + cell.reset(); + assert!(matches!(get(&mut cell, &path, &decodes), Err(FileError::IsDirectory))); + + std::fs::remove_dir(&path).unwrap(); + std::fs::write(&path, "a file now").unwrap(); + cell.reset(); + + assert_eq!(get(&mut cell, &path, &decodes).unwrap(), "a file now"); + } + + /// The racy window is closed at its lower bound: a file exactly two seconds old is + /// settled, one a moment younger is not, and one dated in the future never is. + #[test] + fn a_stamp_settles_after_the_racy_window() { + let modified = an_hour_ago(); + let stamp = FileStat { len: 0, modified }; + let millis = std::time::Duration::from_millis; + + assert!(!stamp.is_settled(modified - millis(1))); + assert!(!stamp.is_settled(modified)); + assert!(!stamp.is_settled(modified + RACY_WINDOW - millis(1))); + assert!(stamp.is_settled(modified + RACY_WINDOW)); + assert!(stamp.is_settled(modified + std::time::Duration::from_secs(3600))); + } }