diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bca0d5da..e75458bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** On Linux and macOS, editing `pnpm-workspace.yaml`, for example to add a catalog entry, no longer causes a cache miss in every task that loads a Vite config. A file a task only checks the existence of, as Vite does with `pnpm-workspace.yaml` and `lerna.json` while looking for the workspace root, is now an input by whether it exists rather than by its content. Creating or removing such a file is still a cache miss, and a task that reads the file still misses when it changes. On Windows, Node.js's `fs.existsSync` also reads the file's metadata, so the file stays a content input there ([#860](https://github.com/voidzero-dev/vite-task/issues/860), [#869](https://github.com/voidzero-dev/vite-task/pull/869)). - **Fixed** A mistyped or misplaced setting in a task now gets an error that names it. For example, `cache: { outputs: [...] }` now fails with an `unknown field` error that names `outputs` and lists the accepted settings, instead of `data did not match any variant of untagged enum UserTaskDefinition` ([vite-plus#2635](https://github.com/voidzero-dev/vite-plus/issues/2635), [#815](https://github.com/voidzero-dev/vite-task/pull/815)). - **Fixed** In cached tasks on Linux and macOS, programs that pass `-1` as a directory descriptor to calls such as `openat` no longer abort. For example, `ps -p ` from procps-ng 4.0.6 or later aborted when the process had already exited ([#862](https://github.com/voidzero-dev/vite-task/pull/862)). - **Fixed** `vp run` no longer panics before running a script that contains non-ASCII characters, such as `echo こんにちは`, and no longer cuts off such commands where it displays them ([#861](https://github.com/voidzero-dev/vite-task/pull/861)). diff --git a/Cargo.lock b/Cargo.lock index 40dad5d5e..a0e7cc626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5044,6 +5044,7 @@ dependencies = [ "vt_str", "vt_workspace", "which", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index 16683d743..cca65490b 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -51,7 +51,7 @@ fspy_preload_unix = { workspace = true } fspy_detours_sys = { workspace = true } fspy_preload_windows = { workspace = true } ntapi = { workspace = true } -winapi = { workspace = true, features = ["winbase", "securitybaseapi", "handleapi"] } +winapi = { workspace = true, features = ["winbase", "securitybaseapi", "handleapi", "fileapi"] } winsafe = { workspace = true, features = ["kernel"] } [target.'cfg(target_os = "macos")'.dev-dependencies] diff --git a/crates/fspy/src/unix/syscall_handler/mod.rs b/crates/fspy/src/unix/syscall_handler/mod.rs index f3c4661e0..4289fabaf 100644 --- a/crates/fspy/src/unix/syscall_handler/mod.rs +++ b/crates/fspy/src/unix/syscall_handler/mod.rs @@ -16,7 +16,7 @@ use fspy_seccomp_unotify::{ impl_handler, supervisor::handler::arg::{CStrPtr, Caller, Fd}, }; -use fspy_shared::ipc::{PathAccess, channel::Sender}; +use fspy_shared::ipc::{AccessMode, PathAccess, channel::Sender}; const PATH_MAX: usize = libc::PATH_MAX as usize; @@ -41,6 +41,27 @@ impl SyscallHandler { dir_fd: Fd, path_ptr: CStrPtr, flags: c_int, + ) -> io::Result<()> { + let mode = match flags & libc::O_ACCMODE { + libc::O_RDWR => AccessMode::ReadWrite, + libc::O_WRONLY => AccessMode::Write, + _ => AccessMode::Read, + }; + self.handle_path(caller, dir_fd, path_ptr, mode) + } + + fn handle_probe(&mut self, caller: Caller, dir_fd: Fd, path_ptr: CStrPtr) -> io::Result<()> { + self.handle_path(caller, dir_fd, path_ptr, AccessMode::Probe) + } + + /// Records a `mode` access to the path at `path_ptr`, resolved against + /// `dir_fd`. + fn handle_path( + &mut self, + caller: Caller, + dir_fd: Fd, + path_ptr: CStrPtr, + mode: AccessMode, ) -> io::Result<()> { let Some(path_len) = path_ptr.read(caller, &mut self.path_read_buf)? else { // Ignore paths that are too long to fit in PATH_MAX @@ -54,12 +75,7 @@ impl SyscallHandler { } path = Cow::Owned(resolved_path); } - let path = path.as_os_str().into(); - self.ipc_sender.send(&match flags & libc::O_ACCMODE { - libc::O_RDWR => PathAccess::ReadWrite(path), - libc::O_WRONLY => PathAccess::Write(path), - _ => PathAccess::Read(path), - }); + self.ipc_sender.send(&mode.with_path(path.as_os_str().into())); Ok(()) } diff --git a/crates/fspy/src/unix/syscall_handler/stat.rs b/crates/fspy/src/unix/syscall_handler/stat.rs index 40d9f76f1..683e47907 100644 --- a/crates/fspy/src/unix/syscall_handler/stat.rs +++ b/crates/fspy/src/unix/syscall_handler/stat.rs @@ -45,7 +45,7 @@ impl SyscallHandler { /// access(2) — check file accessibility (e.g. existsSync in Node.js). #[cfg(target_arch = "x86_64")] pub(super) fn access(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> { - self.handle_open(caller, Fd::cwd(), path, libc::O_RDONLY) + self.handle_probe(caller, Fd::cwd(), path) } /// faccessat(2) — check file accessibility relative to directory fd. @@ -54,7 +54,7 @@ impl SyscallHandler { caller: Caller, (dir_fd, path_ptr): (Fd, CStrPtr), ) -> io::Result<()> { - self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY) + self.handle_probe(caller, dir_fd, path_ptr) } /// faccessat2(2) — extended faccessat with flags parameter. @@ -63,6 +63,6 @@ impl SyscallHandler { caller: Caller, (dir_fd, path_ptr): (Fd, CStrPtr), ) -> io::Result<()> { - self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY) + self.handle_probe(caller, dir_fd, path_ptr) } } diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index 3940b4dc2..55e14e961 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -76,7 +76,7 @@ fn read_sync(runtime: &str) -> anyhow::Result<()> { #[cfg_attr(not(target_env = "musl"), ignore = "requires node")] fn exist_sync(runtime: &str) -> anyhow::Result<()> { let accesses = track_script(runtime, "try { fs.existsSync('hello') } catch {}", &[])?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Probe); Ok(()) } diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index 3ace70546..33fe31772 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -107,6 +107,12 @@ async fn stat() { assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Read); } +#[test(tokio::test)] +async fn access() { + let accesses = track_test_bin(&["access", "/hello"], None).await; + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Probe); +} + #[test(tokio::test)] async fn execve() { let accesses = track_test_bin(&["execve", "/hello"], None).await; diff --git a/crates/fspy/tests/winapi.rs b/crates/fspy/tests/winapi.rs index 870507d8b..4715186e0 100644 --- a/crates/fspy/tests/winapi.rs +++ b/crates/fspy/tests/winapi.rs @@ -6,8 +6,11 @@ use std::{ffi::OsStr, os::windows::ffi::OsStrExt, path::Path, ptr::null_mut}; use fspy::PathAccess; use test_log::test; use test_utils::assert_contains; -use winapi::um::processthreadsapi::{ - CreateProcessA, CreateProcessW, PROCESS_INFORMATION, STARTUPINFOA, STARTUPINFOW, +use winapi::um::{ + fileapi::GetFileAttributesW, + processthreadsapi::{ + CreateProcessA, CreateProcessW, PROCESS_INFORMATION, STARTUPINFOA, STARTUPINFOW, + }, }; #[test(tokio::test)] @@ -73,3 +76,16 @@ async fn create_process_w() -> anyhow::Result<()> { Ok(()) } + +#[test(tokio::test)] +async fn get_file_attributes_w() -> anyhow::Result<()> { + let accesses = track_fn!((), |(): ()| { + let path = OsStr::new("C:\\fspy_test_not_exist_file\0").encode_wide().collect::>(); + // SAFETY: `path` is a valid NUL-terminated wide string + unsafe { GetFileAttributesW(path.as_ptr()) }; + }) + .await?; + assert_contains(&accesses, Path::new("C:\\fspy_test_not_exist_file"), PathAccess::Probe); + + Ok(()) +} diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index 4af904666..abf44f888 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -49,6 +49,7 @@ impl AccessCollector { PathAccess::Write(path) => relative(path).map(|path| format!("{path}: Write")), PathAccess::ReadWrite(path) => relative(path).map(|path| format!("{path}: ReadWrite")), PathAccess::ReadDir(path) => relative(path).map(|path| format!("{path}: ReadDir")), + PathAccess::Probe(path) => relative(path).map(|path| format!("{path}: Probe")), }; self.accesses.extend(line); } diff --git a/crates/fspy_preload_unix/src/interceptions/access.rs b/crates/fspy_preload_unix/src/interceptions/access.rs index db220a314..9afacd9f7 100644 --- a/crates/fspy_preload_unix/src/interceptions/access.rs +++ b/crates/fspy_preload_unix/src/interceptions/access.rs @@ -10,7 +10,7 @@ intercept!(access(64): unsafe extern "C" fn(pathname: *const c_char, mode: c_int unsafe extern "C" fn access(pathname: *const c_char, mode: c_int) -> c_int { // SAFETY: pathname is a valid C string pointer provided by the caller of the interposed function unsafe { - handle_open(fspy_nostd::CStr::from_ptr(pathname.cast()), AccessMode::Read); + handle_open(fspy_nostd::CStr::from_ptr(pathname.cast()), AccessMode::Probe); } // SAFETY: calling the original libc access() with the same arguments forwarded from the interposed function unsafe { access::original()(pathname, mode) } @@ -25,7 +25,7 @@ unsafe extern "C" fn faccessat( ) -> c_int { // SAFETY: dirfd is -1 or a valid file descriptor, and pathname is a valid C string, provided by the caller of the interposed function unsafe { - handle_open(PathAt::borrow_raw(dirfd, pathname), AccessMode::Read); + handle_open(PathAt::borrow_raw(dirfd, pathname), AccessMode::Probe); } // SAFETY: calling the original libc faccessat() with the same arguments forwarded from the interposed function unsafe { faccessat::original()(dirfd, pathname, mode, flags) } diff --git a/crates/fspy_preload_windows/src/windows/detours/nt.rs b/crates/fspy_preload_windows/src/windows/detours/nt.rs index 1cf8f591d..481f76fc1 100644 --- a/crates/fspy_preload_windows/src/windows/detours/nt.rs +++ b/crates/fspy_preload_windows/src/windows/detours/nt.rs @@ -269,8 +269,12 @@ static DETOUR_NT_QUERY_ATTRIBUTES_FILE: Detour< object_attributes: POBJECT_ATTRIBUTES, file_information: PFILE_BASIC_INFORMATION, ) -> HFILE { - // SAFETY: intercepting attribute query to record read access - unsafe { handle_open(AccessMode::Read, object_attributes) }; + // NtQueryAttributesFile returns only attributes and timestamps, not + // the size, and backs `GetFileAttributesW`, which Node's + // `fs.existsSync` calls. NtQueryFullAttributesFile returns the size + // too, so it stays a read. + // SAFETY: intercepting attribute query to record the existence probe + unsafe { handle_open(AccessMode::Probe, object_attributes) }; // SAFETY: calling the original NtQueryAttributesFile with all original arguments unsafe { (DETOUR_NT_QUERY_ATTRIBUTES_FILE.real())(object_attributes, file_information) diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index 443e09744..df3c6bbd5 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -20,6 +20,7 @@ pub enum AccessMode { Write, ReadWrite, ReadDir, + Probe, } impl AccessMode { @@ -30,6 +31,7 @@ impl AccessMode { Self::Write => PathAccess::Write(path), Self::ReadWrite => PathAccess::ReadWrite(path), Self::ReadDir => PathAccess::ReadDir(path), + Self::Probe => PathAccess::Probe(path), } } } @@ -43,6 +45,9 @@ pub enum PathAccess<'a> { Write(&'a IpcPath), ReadWrite(&'a IpcPath), ReadDir(&'a IpcPath), + /// Checked whether the path exists, as `access` does, without reading + /// its content or metadata such as the size. + Probe(&'a IpcPath), } impl<'a> PathAccess<'a> { @@ -52,7 +57,8 @@ impl<'a> PathAccess<'a> { PathAccess::Read(path) | PathAccess::Write(path) | PathAccess::ReadWrite(path) - | PathAccess::ReadDir(path) => iter::once(path), + | PathAccess::ReadDir(path) + | PathAccess::Probe(path) => iter::once(path), } } @@ -63,6 +69,7 @@ impl<'a> PathAccess<'a> { PathAccess::Write(path) => PathAccess::Write(path.clone_in(bump)), PathAccess::ReadWrite(path) => PathAccess::ReadWrite(path.clone_in(bump)), PathAccess::ReadDir(path) => PathAccess::ReadDir(path.clone_in(bump)), + PathAccess::Probe(path) => PathAccess::Probe(path.clone_in(bump)), } } } diff --git a/crates/fspy_test_bin/src/main.rs b/crates/fspy_test_bin/src/main.rs index 35e548568..2c361b077 100644 --- a/crates/fspy_test_bin/src/main.rs +++ b/crates/fspy_test_bin/src/main.rs @@ -40,6 +40,9 @@ fn main() { "stat" => { let _ = std::fs::metadata(path); } + "access" => { + let _ = nix::unistd::access(path, nix::unistd::AccessFlags::F_OK); + } "execve" => { let _ = std::process::Command::new(path).spawn(); } diff --git a/crates/vt/docs/task-cache.md b/crates/vt/docs/task-cache.md index b200c528f..79069a3c9 100644 --- a/crates/vt/docs/task-cache.md +++ b/crates/vt/docs/task-cache.md @@ -198,6 +198,8 @@ Vite Task uses `fspy` to monitor file system access during task execution: │ • If file: Hash contents with xxHash3 │ │ • If directory: Record structure │ │ • If missing: Mark as NotFound │ +│ • If only checked to exist (`access`): Record whether it │ +│ exists and is a directory, not its content │ │ │ │ │ ▼ │ │ Path Fingerprint Types: │ @@ -205,6 +207,7 @@ Vite Task uses `fspy` to monitor file system access during task execution: │ enum PathFingerprint { │ │ NotFound, // File doesn't exist │ │ FileContentHash(u64), // xxHash3 of content │ +│ FileExists, // File only checked │ │ Folder(Option>), │ │ } ▲ │ │ │ │ diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 2caefd817..6425f69d7 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -276,7 +276,7 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) { /// its own cache warm across branch switches, and a cache from a different /// version is simply ignored (it lives in a directory this build never looks /// at) rather than aborting the run. Bumping the version starts a fresh cache. -const CACHE_SCHEMA_VERSION: u32 = 18; +const CACHE_SCHEMA_VERSION: u32 = 19; /// Name of the per-version subdirectory (e.g. `v14`) under the task-cache /// directory that holds the database and output archives for the current diff --git a/crates/vt/src/session/execute/fingerprint.rs b/crates/vt/src/session/execute/fingerprint.rs index 32f4cb880..be443f51f 100644 --- a/crates/vt/src/session/execute/fingerprint.rs +++ b/crates/vt/src/session/execute/fingerprint.rs @@ -37,6 +37,9 @@ pub enum TrackedEnvQuery { #[derive(Debug, Clone, Copy)] pub struct PathRead { pub read_dir_entries: bool, + /// The task only checked whether the path exists, so neither its content + /// nor its directory entries are inputs. + pub exists_only: bool, } /// Post-run fingerprint capturing file state after execution. @@ -86,6 +89,9 @@ pub enum PathFingerprint { NotFound, /// File content hash using `xxHash3_64` FileContentHash(u64), + /// File that exists, for a path the task only checked the existence of. + /// An existing directory checked this way is `Folder(None)`. + FileExists, /// Directory with optional entry listing. /// `Folder(None)` means the directory was opened but entries were not read /// (e.g., for `openat` calls). @@ -158,6 +164,7 @@ impl PostRunFingerprint { let input_full_path = Arc::::from(base_dir.join(input_relative_path)); let path_read = PathRead { read_dir_entries: matches!(path_fingerprint, PathFingerprint::Folder(Some(_))), + exists_only: matches!(path_fingerprint, PathFingerprint::FileExists), }; let current_path_fingerprint = match fingerprint_path(&input_full_path, path_read) { Ok(ok) => ok, @@ -374,6 +381,15 @@ pub fn fingerprint_path( ) -> anyhow::Result { let std_path = path.as_path(); + if path_read.exists_only { + // Like the open below, any error counts as the path not existing. + return Ok(match std::fs::metadata(std_path) { + Ok(metadata) if metadata.is_dir() => PathFingerprint::Folder(None), + Ok(_) => PathFingerprint::FileExists, + Err(_) => PathFingerprint::NotFound, + }); + } + let file = match File::open(std_path) { Ok(file) => file, Err(err) => { @@ -500,8 +516,32 @@ fn process_directory_unix(file: &File, path_read: PathRead) -> anyhow::Result::from(dir.join(name)); + fingerprint_path(&path, PathRead { read_dir_entries: false, exists_only: true }) + .unwrap() + }; + + std::fs::write(dir.join("file"), "a").unwrap(); + assert_eq!(probe("file"), PathFingerprint::FileExists); + std::fs::write(dir.join("file"), "b").unwrap(); + assert_eq!(probe("file"), PathFingerprint::FileExists); + + std::fs::create_dir(dir.join("dir")).unwrap(); + std::fs::write(dir.join("dir/entry"), "").unwrap(); + assert_eq!(probe("dir"), PathFingerprint::Folder(None)); + + assert_eq!(probe("missing"), PathFingerprint::NotFound); + } + #[cfg(unix)] fn non_utf8_os_string() -> OsString { use std::os::unix::ffi::OsStringExt; diff --git a/crates/vt/src/session/execute/tracked_accesses.rs b/crates/vt/src/session/execute/tracked_accesses.rs index 2e0a05441..98073d90d 100644 --- a/crates/vt/src/session/execute/tracked_accesses.rs +++ b/crates/vt/src/session/execute/tracked_accesses.rs @@ -60,13 +60,33 @@ impl TrackedPathAccesses { accesses.add_read(path).read_dir_entries = true; } } + PathAccess::Probe(path) => { + if let Some(path) = relative_path(path) { + accesses.add_probe(path); + } + } } } accesses } + /// Records that the task checked whether `path` exists. Only its + /// existence is an input, unless the task also reads it. + fn add_probe(&mut self, path: RelativePathBuf) { + self.path_reads + .entry(path) + .or_insert(PathRead { read_dir_entries: false, exists_only: true }); + } + + /// Records a read of `path`. A read of a path the task also probed makes + /// its content an input, whichever came first. fn add_read(&mut self, path: RelativePathBuf) -> &mut PathRead { - self.path_reads.entry(path).or_insert(PathRead { read_dir_entries: false }) + let path_read = self + .path_reads + .entry(path) + .or_insert(PathRead { read_dir_entries: false, exists_only: false }); + path_read.exists_only = false; + path_read } } @@ -130,6 +150,25 @@ mod tests { assert!(normalize("node_modules/a/.pnpm-workspace-state-v1.json").is_some()); } + #[test] + fn read_of_probed_path_tracks_content() { + let path = || RelativePathBuf::new("pnpm-workspace.yaml").unwrap(); + + let mut probed = TrackedPathAccesses::default(); + probed.add_probe(path()); + assert!(probed.path_reads[&path()].exists_only); + + let mut probe_then_read = TrackedPathAccesses::default(); + probe_then_read.add_probe(path()); + probe_then_read.add_read(path()); + assert!(!probe_then_read.path_reads[&path()].exists_only); + + let mut read_then_probe = TrackedPathAccesses::default(); + read_then_probe.add_read(path()); + read_then_probe.add_probe(path()); + assert!(!read_then_probe.path_reads[&path()].exists_only); + } + #[cfg(windows)] #[test] fn malformed_windows_drive_path_after_workspace_strip_is_ignored() { diff --git a/crates/vt_bin/Cargo.toml b/crates/vt_bin/Cargo.toml index 0b39cb66a..247d3fa02 100644 --- a/crates/vt_bin/Cargo.toml +++ b/crates/vt_bin/Cargo.toml @@ -34,6 +34,9 @@ which = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] nix = { workspace = true, features = ["mount", "sched", "user"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] cargo-platform = { workspace = true } cow-utils = { workspace = true } diff --git a/crates/vt_bin/src/vtt/exists.rs b/crates/vt_bin/src/vtt/exists.rs new file mode 100644 index 000000000..4cf3c6346 --- /dev/null +++ b/crates/vt_bin/src/vtt/exists.rs @@ -0,0 +1,36 @@ +//! Prints whether each path exists, without opening or statting it. +//! +//! Usage: `vtt exists ...` +//! +//! The check makes the call Node's `fs.existsSync` makes, `access` on Unix +//! and `GetFileAttributesW` on Windows, so tests can make the existence +//! probes tools like Vite make while looking for the workspace root. +//! `vtt stat-file` stats instead, which reads metadata such as the size. +pub fn run(args: &[String]) -> Result<(), Box> { + for path in args { + if exists(path)? { + println!("{path}: exists"); + } else { + println!("{path}: missing"); + } + } + Ok(()) +} + +#[cfg(unix)] +fn exists(path: &str) -> Result> { + let path = std::ffi::CString::new(path)?; + // SAFETY: `path` is a valid NUL-terminated C string that outlives the call. + Ok(unsafe { libc::access(path.as_ptr(), libc::F_OK) } == 0) +} + +#[cfg(windows)] +fn exists(path: &str) -> Result> { + use std::os::windows::ffi::OsStrExt as _; + + use windows_sys::Win32::Storage::FileSystem::{GetFileAttributesW, INVALID_FILE_ATTRIBUTES}; + + let path: Vec = std::ffi::OsStr::new(path).encode_wide().chain(Some(0)).collect(); + // SAFETY: `path` is a valid NUL-terminated wide string that outlives the call. + Ok(unsafe { GetFileAttributesW(path.as_ptr()) } != INVALID_FILE_ATTRIBUTES) +} diff --git a/crates/vt_bin/src/vtt/main.rs b/crates/vt_bin/src/vtt/main.rs index e77c719df..b559b6428 100644 --- a/crates/vt_bin/src/vtt/main.rs +++ b/crates/vt_bin/src/vtt/main.rs @@ -9,6 +9,7 @@ mod barrier; mod check_tty; mod cp; +mod exists; mod exit; mod exit_on_ctrlc; mod grep_file; @@ -36,7 +37,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stalled-remote-cache, stat-file, stat-many, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exists, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stalled-remote-cache, stat-file, stat-many, touch-file, write-file" ); std::process::exit(1); } @@ -48,6 +49,7 @@ fn main() { Ok(()) } "cp" => cp::run(&args[2..]), + "exists" => exists::run(&args[2..]), "exit" => exit::run(&args[2..]), "exit-on-ctrlc" => exit_on_ctrlc::run(), "grep-file" => { diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/package.json new file mode 100644 index 000000000..d491841e8 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/package.json @@ -0,0 +1,4 @@ +{ + "name": "existence-probe", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/packages/app/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/packages/app/package.json new file mode 100644 index 000000000..0a2d4152f --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "app", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/packages/app/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/packages/app/vite-task.json new file mode 100644 index 000000000..231cbbcab --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/packages/app/vite-task.json @@ -0,0 +1,12 @@ +{ + "tasks": { + "find-root": { + "command": "vtt exists ../../pnpm-workspace.yaml ../../lerna.json", + "cache": true + }, + "read-manifest": { + "command": "vtt print-file ../../pnpm-workspace.yaml", + "cache": true + } + } +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/pnpm-workspace.yaml b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/pnpm-workspace.yaml new file mode 100644 index 000000000..23453fb7c --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - packages/* + +catalog: + vite: 8.0.0 diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots.toml new file mode 100644 index 000000000..214c4bacd --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots.toml @@ -0,0 +1,72 @@ +[[e2e]] +name = "existence_probe_content_ignored" +comment = """ +A task that only checks whether files exist, as Vite does while looking for the workspace root, depends on whether they exist and not on their content. Editing `pnpm-workspace.yaml` keeps the cache hit, and creating or removing `lerna.json` is a cache miss. +""" +cwd = "packages/app" +steps = [ + { argv = [ + "vt", + "run", + "find-root", + ], comment = "cache miss" }, + { argv = [ + "vtt", + "replace-file-content", + "../../pnpm-workspace.yaml", + "vite: 8.0.0", + "vite: 8.0.0\n left-pad: 1.3.0", + ], comment = "add an unused catalog entry" }, + { argv = [ + "vt", + "run", + "find-root", + ], comment = "cache hit" }, + { argv = [ + "vtt", + "write-file", + "../../lerna.json", + "{}", + ], comment = "create a root marker" }, + { argv = [ + "vt", + "run", + "find-root", + ], comment = "cache miss: input added" }, + { argv = [ + "vtt", + "rm", + "../../lerna.json", + ], comment = "remove the root marker" }, + { argv = [ + "vt", + "run", + "find-root", + ], comment = "cache miss: input removed" }, +] + +[[e2e]] +name = "existence_probe_read_tracks_content" +comment = """ +A task that reads `pnpm-workspace.yaml` still depends on its content, so editing it is a cache miss. +""" +cwd = "packages/app" +steps = [ + { argv = [ + "vt", + "run", + "read-manifest", + ], comment = "cache miss" }, + { argv = [ + "vtt", + "replace-file-content", + "../../pnpm-workspace.yaml", + "vite: 8.0.0", + "vite: 8.0.0\n left-pad: 1.3.0", + ], comment = "add an unused catalog entry" }, + { argv = [ + "vt", + "run", + "read-manifest", + ], comment = "cache miss: input modified" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots/existence_probe_content_ignored.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots/existence_probe_content_ignored.md new file mode 100644 index 000000000..f2c523b94 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots/existence_probe_content_ignored.md @@ -0,0 +1,68 @@ +# existence_probe_content_ignored + +A task that only checks whether files exist, as Vite does while looking for the workspace root, depends on whether they exist and not on their content. Editing `pnpm-workspace.yaml` keeps the cache hit, and creating or removing `lerna.json` is a cache miss. + +## `vt run find-root` + +cache miss + +``` +~/packages/app$ vtt exists ../../pnpm-workspace.yaml ../../lerna.json +../../pnpm-workspace.yaml: exists +../../lerna.json: missing +``` + +## `vtt replace-file-content ../../pnpm-workspace.yaml 'vite: 8.0.0' 'vite: 8.0.0 + left-pad: 1.3.0'` + +add an unused catalog entry + +``` +``` + +## `vt run find-root` + +cache hit + +``` +~/packages/app$ vtt exists ../../pnpm-workspace.yaml ../../lerna.json ◉ cache hit, replaying +../../pnpm-workspace.yaml: exists +../../lerna.json: missing + +--- +vt run: cache hit. +``` + +## `vtt write-file ../../lerna.json {}` + +create a root marker + +``` +``` + +## `vt run find-root` + +cache miss: input added + +``` +~/packages/app$ vtt exists ../../pnpm-workspace.yaml ../../lerna.json ○ cache miss: 'lerna.json' added in workspace root, executing +../../pnpm-workspace.yaml: exists +../../lerna.json: exists +``` + +## `vtt rm ../../lerna.json` + +remove the root marker + +``` +``` + +## `vt run find-root` + +cache miss: input removed + +``` +~/packages/app$ vtt exists ../../pnpm-workspace.yaml ../../lerna.json ○ cache miss: 'lerna.json' removed from workspace root, executing +../../pnpm-workspace.yaml: exists +../../lerna.json: missing +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots/existence_probe_read_tracks_content.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots/existence_probe_read_tracks_content.md new file mode 100644 index 000000000..530fc69ea --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/existence_probe/snapshots/existence_probe_read_tracks_content.md @@ -0,0 +1,38 @@ +# existence_probe_read_tracks_content + +A task that reads `pnpm-workspace.yaml` still depends on its content, so editing it is a cache miss. + +## `vt run read-manifest` + +cache miss + +``` +~/packages/app$ vtt print-file ../../pnpm-workspace.yaml +packages: + - packages/* + +catalog: + vite: 8.0.0 +``` + +## `vtt replace-file-content ../../pnpm-workspace.yaml 'vite: 8.0.0' 'vite: 8.0.0 + left-pad: 1.3.0'` + +add an unused catalog entry + +``` +``` + +## `vt run read-manifest` + +cache miss: input modified + +``` +~/packages/app$ vtt print-file ../../pnpm-workspace.yaml ○ cache miss: 'pnpm-workspace.yaml' modified, executing +packages: + - packages/* + +catalog: + vite: 8.0.0 + left-pad: 1.3.0 +``` diff --git a/docs/inputs.md b/docs/inputs.md index d9f48b807..344efffa4 100644 --- a/docs/inputs.md +++ b/docs/inputs.md @@ -18,6 +18,8 @@ When `input` is omitted, vite-task automatically tracks files that the command r Files read by `tsc` are automatically tracked. **In most cases, you don't need to configure `input` at all.** +A file the command only checks the existence of, for example with `fs.existsSync` on Linux and macOS, is tracked by whether it exists, not by its content. Creating or removing it invalidates the cache, but editing it doesn't, unless the command also reads it. + ## Input Types ### Glob Patterns