diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f1d90be..9a13c6c8f 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 ([#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)). 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()); + } }