From d08f173572941dd32624f1851e89cac376dcbd81 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 28 Oct 2026 15:42:07 -0400 Subject: [PATCH 1/3] fix(cache): leave outputs that already match untouched when restoring a cache hit Restoring a cache hit unpacked every archived output over the workspace, so tar removed and recreated each file even when its content was unchanged. A file watcher saw every output change on every hit. Compare each regular-file entry with the file on disk first: a file with the same size, permissions and bytes is left as it is. A missing file, or one that differs in type, size or permissions, is unpacked right away, as before. One that matches in size but differs in content was found by reading the entry's data, so it is unpacked in a second pass over the archive. Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/archive.rs | 223 ++++++++++++++++++++++++- 1 file changed, 218 insertions(+), 5 deletions(-) diff --git a/crates/vt/src/session/cache/archive.rs b/crates/vt/src/session/cache/archive.rs index dc110e29c..a6cd5a238 100644 --- a/crates/vt/src/session/cache/archive.rs +++ b/crates/vt/src/session/cache/archive.rs @@ -3,6 +3,7 @@ use std::{ fs::File, io::{self, Read}, + path::Component, }; use vt_path::{AbsolutePath, RelativePathBuf}; @@ -48,7 +49,12 @@ pub fn create_output_archive( /// Extract a tar.zst archive, restoring files relative to workspace root. /// -/// Parent directories are created automatically. Existing files are overwritten. +/// A file that already has its entry's content and permissions is left as it +/// is, modification time included, so restoring outputs that didn't change +/// writes nothing and file watchers see no change. Every other entry is +/// unpacked as [`tar::Archive::unpack`] would: parent directories are created +/// and an existing file is replaced. (`Archive::unpack` also defers directory +/// entries to the end, and [`create_output_archive`] writes none.) /// /// # Errors /// @@ -57,14 +63,127 @@ pub fn extract_output_archive( workspace_root: &AbsolutePath, archive_path: &AbsolutePath, ) -> anyhow::Result<()> { - let file = File::open(archive_path.as_path())?; - let decoder = zstd::Decoder::new(file)?; - let mut archive = tar::Archive::new(decoder); + // Canonicalized once, as `Archive::unpack` does: on Windows this gives the + // `\\?\` prefix that lifts the 260-character path limit. + let dst = workspace_root + .as_path() + .canonicalize() + .unwrap_or_else(|_| workspace_root.as_path().to_path_buf()); + + // Comparing an entry with a file of the same size reads the entry's data, + // so an entry found to differ that way is unpacked in a second pass. + let mut read_and_differ = Vec::new(); + let mut archive = open_output_archive(archive_path)?; + for (index, entry) in archive.entries()?.enumerate() { + let mut entry = entry?; + match compare_with_disk(workspace_root, &mut entry)? { + OnDisk::Matches => {} + OnDisk::Differs => { + entry.unpack_in(&dst)?; + } + OnDisk::DiffersAfterRead => read_and_differ.push(index), + } + } - archive.unpack(workspace_root.as_path())?; + let mut pending = read_and_differ.into_iter().peekable(); + if pending.peek().is_some() { + let mut archive = open_output_archive(archive_path)?; + for (index, entry) in archive.entries()?.enumerate() { + let Some(&next) = pending.peek() else { break }; + let mut entry = entry?; + if index == next { + entry.unpack_in(&dst)?; + pending.next(); + } + } + } Ok(()) } +fn open_output_archive( + archive_path: &AbsolutePath, +) -> io::Result>>> { + let file = File::open(archive_path.as_path())?; + Ok(tar::Archive::new(zstd::Decoder::new(file)?)) +} + +/// How the file an archive entry restores compares with the entry. +enum OnDisk { + /// The file has the entry's content and permissions. + Matches, + /// The entry isn't a regular file, or the file is missing or differs in + /// type, size or permissions. The entry's data is unread. + Differs, + /// The file differs in content, found by reading the entry's data. + DiffersAfterRead, +} + +fn compare_with_disk( + workspace_root: &AbsolutePath, + entry: &mut tar::Entry<'_, impl Read>, +) -> io::Result { + if !entry.header().entry_type().is_file() { + return Ok(OnDisk::Differs); + } + let Ok(mode) = entry.header().mode() else { + return Ok(OnDisk::Differs); + }; + let target = match entry.path() { + // Any other path is left to `unpack_in`, which skips or rejects it. + Ok(path) if path.components().all(|part| matches!(part, Component::Normal(_))) => { + workspace_root.join(path) + } + _ => return Ok(OnDisk::Differs), + }; + let Ok(metadata) = std::fs::symlink_metadata(target.as_path()) else { + return Ok(OnDisk::Differs); + }; + if !metadata.is_file() + || metadata.len() != entry.size() + || !has_unpacked_permissions(&metadata, mode) + { + return Ok(OnDisk::Differs); + } + let Ok(on_disk) = File::open(target.as_path()) else { + return Ok(OnDisk::Differs); + }; + Ok(if same_content(entry, on_disk)? { OnDisk::Matches } else { OnDisk::DiffersAfterRead }) +} + +/// Whether two readers of the same length yield the same bytes. Only errors +/// reading `archived` are returned; one reading `on_disk` counts as a +/// difference. +fn same_content(mut archived: impl Read, mut on_disk: impl Read) -> io::Result { + let mut archived_buf = [0; 16 * 1024]; + let mut on_disk_buf = [0; 16 * 1024]; + loop { + let len = archived.read(&mut archived_buf)?; + if len == 0 { + return Ok(true); + } + if on_disk.read_exact(&mut on_disk_buf[..len]).is_err() + || archived_buf[..len] != on_disk_buf[..len] + { + return Ok(false); + } + } +} + +/// Whether a file has the permissions `unpack_in` gives a file from an entry +/// with `mode`: its permission bits, without setuid, setgid or sticky. +#[cfg(unix)] +fn has_unpacked_permissions(metadata: &std::fs::Metadata, mode: u32) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o7777 == mode & 0o777 +} + +/// Whether a file has the permissions `unpack_in` gives a file from an entry +/// with `mode`: read-only when the mode lacks the owner's write bit. +#[cfg(windows)] +fn has_unpacked_permissions(metadata: &std::fs::Metadata, mode: u32) -> bool { + metadata.permissions().readonly() == (mode & 0o200 == 0) +} + /// Read a tar.zst archive from `reader` to the end without writing any files, /// to check that it decodes. /// @@ -103,4 +222,98 @@ mod tests { assert!(check_output_archive(corrupt).is_err()); } } + + /// Writes each `(name, content)` output into a temporary workspace and + /// archives them all. + fn archived_outputs(outputs: &[(&str, &str)]) -> (tempfile::TempDir, AbsolutePathBuf) { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + let mut paths = Vec::new(); + for (name, content) in outputs { + let path = RelativePathBuf::new(name).unwrap(); + std::fs::write(dir.join(&path).as_path(), content).unwrap(); + paths.push(path); + } + let archive_path = dir.join("output.tar.zst"); + create_output_archive(&dir, &paths, &archive_path).unwrap(); + (tmp, archive_path) + } + + /// A modification time long before the archive was made, which a restore + /// that rewrites the file replaces with the archived one. + fn mark_untouched(path: &AbsolutePath) -> std::time::SystemTime { + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000); + File::options().write(true).open(path.as_path()).unwrap().set_modified(time).unwrap(); + time + } + + fn modified(path: &AbsolutePath) -> std::time::SystemTime { + std::fs::metadata(path.as_path()).unwrap().modified().unwrap() + } + + #[test] + fn extract_leaves_matching_files_untouched() { + let (tmp, archive_path) = archived_outputs(&[("a.txt", "a\n"), ("b.txt", "b\n")]); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + let a = dir.join("a.txt"); + let b = dir.join("b.txt"); + let untouched = [mark_untouched(&a), mark_untouched(&b)]; + + extract_output_archive(&dir, &archive_path).unwrap(); + + assert_eq!([modified(&a), modified(&b)], untouched); + assert_eq!(std::fs::read_to_string(a.as_path()).unwrap(), "a\n"); + assert_eq!(std::fs::read_to_string(b.as_path()).unwrap(), "b\n"); + } + + #[test] + fn extract_restores_missing_and_changed_files() { + let (tmp, archive_path) = archived_outputs(&[ + ("missing.txt", "missing\n"), + ("resized.txt", "resized\n"), + ("same-size.txt", "same size\n"), + ("unchanged.txt", "unchanged\n"), + ]); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + std::fs::remove_file(dir.join("missing.txt").as_path()).unwrap(); + std::fs::write(dir.join("resized.txt").as_path(), "longer content\n").unwrap(); + std::fs::write(dir.join("same-size.txt").as_path(), "SAME SIZE\n").unwrap(); + let unchanged = dir.join("unchanged.txt"); + let untouched = mark_untouched(&unchanged); + + extract_output_archive(&dir, &archive_path).unwrap(); + + for (name, content) in [ + ("missing.txt", "missing\n"), + ("resized.txt", "resized\n"), + ("same-size.txt", "same size\n"), + ("unchanged.txt", "unchanged\n"), + ] { + assert_eq!(std::fs::read_to_string(dir.join(name).as_path()).unwrap(), content); + } + assert_eq!(modified(&unchanged), untouched); + } + + #[test] + fn extract_restores_changed_permissions() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + let path = RelativePathBuf::new("output.txt").unwrap(); + let output = dir.join(&path); + std::fs::write(output.as_path(), "built\n").unwrap(); + let writable = std::fs::metadata(output.as_path()).unwrap().permissions(); + let mut read_only = writable.clone(); + read_only.set_readonly(true); + std::fs::set_permissions(output.as_path(), read_only).unwrap(); + let archive_path = dir.join("output.tar.zst"); + create_output_archive(&dir, &[path], &archive_path).unwrap(); + std::fs::set_permissions(output.as_path(), writable.clone()).unwrap(); + + extract_output_archive(&dir, &archive_path).unwrap(); + + let restored = std::fs::metadata(output.as_path()).unwrap().permissions(); + // Windows can't remove the directory while the file is read-only. + std::fs::set_permissions(output.as_path(), writable).unwrap(); + assert!(restored.readonly()); + } } From 95367b36ea12de728547db7e72e75493250b6ba4 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 28 Oct 2026 15:49:03 -0400 Subject: [PATCH 2/3] docs(changelog): leave unchanged outputs untouched on a cache hit Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f1d90be..07b31eb5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** A cache hit no longer rewrites output files that already have the cached content and permissions, so a file watcher on a task's outputs, such as a Vite dev server or `vite build --watch`, no longer sees every output change when none did. Missing and changed outputs are still restored ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **Fixed** On Linux and macOS, programs using automatic file-access tracking no longer abort when they call `execve`, `execv`, `execl`, `execle`, or `posix_spawn` with an empty path; the call fails with `ENOENT`, as it does without tracking. On Linux, `execveat` with an empty path and without `AT_EMPTY_PATH` now also fails with `ENOENT`, instead of trying to run what its directory descriptor refers to ([#896](https://github.com/voidzero-dev/vite-task/pull/896)). - **Perf** `vp run` starts faster in workspaces whose tasks share cache `input` and `output` patterns: each distinct pattern is now parsed once instead of once for every task that uses it. In a 138-package workspace, this saves about 2 seconds of CPU on every run ([#887](https://github.com/voidzero-dev/vite-task/pull/887), [#890](https://github.com/voidzero-dev/vite-task/pull/890)). - **Perf** When tasks miss the cache, `vp run` no longer waits for the disk after saving each task's result, so the run finishes sooner. If the computer loses power or the operating system crashes during `vp run`, the cache entries that the run saved can be lost, and those tasks run again next time instead of hitting the cache ([#871](https://github.com/voidzero-dev/vite-task/pull/871)). From e9806c12d693158fe853125dcf0788ee07b632f2 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Wed, 28 Oct 2026 15:49:21 -0400 Subject: [PATCH 3/3] docs(changelog): link #901 Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b31eb5a..9a13c6c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** A cache hit no longer rewrites output files that already have the cached content and permissions, so a file watcher on a task's outputs, such as a Vite dev server or `vite build --watch`, no longer sees every output change when none did. Missing and changed outputs are still restored ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** A cache hit no longer rewrites output files that already have the cached content and permissions, so a file watcher on a task's outputs, such as a Vite dev server or `vite build --watch`, no longer sees every output change when none did. Missing and changed outputs are still restored ([#901](https://github.com/voidzero-dev/vite-task/pull/901)). - **Fixed** On Linux and macOS, programs using automatic file-access tracking no longer abort when they call `execve`, `execv`, `execl`, `execle`, or `posix_spawn` with an empty path; the call fails with `ENOENT`, as it does without tracking. On Linux, `execveat` with an empty path and without `AT_EMPTY_PATH` now also fails with `ENOENT`, instead of trying to run what its directory descriptor refers to ([#896](https://github.com/voidzero-dev/vite-task/pull/896)). - **Perf** `vp run` starts faster in workspaces whose tasks share cache `input` and `output` patterns: each distinct pattern is now parsed once instead of once for every task that uses it. In a 138-package workspace, this saves about 2 seconds of CPU on every run ([#887](https://github.com/voidzero-dev/vite-task/pull/887), [#890](https://github.com/voidzero-dev/vite-task/pull/890)). - **Perf** When tasks miss the cache, `vp run` no longer waits for the disk after saving each task's result, so the run finishes sooner. If the computer loses power or the operating system crashes during `vp run`, the cache entries that the run saved can be lost, and those tasks run again next time instead of hitting the cache ([#871](https://github.com/voidzero-dev/vite-task/pull/871)).