From 18504e7f84c43191ea1f17968f65420a5b8a9212 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 24 Oct 2026 19:22:53 -0400 Subject: [PATCH 1/8] fix(cache): restore symlinks in task outputs as links Output archives followed symbolic links and kept regular files only, and output globs listed regular files only, so a cache hit dropped every link a task's output held. Archive links as links, let output globs list them, and restore directory links as directory links on Windows. Bump the cache schema so entries archived without their links are not replayed. --- CHANGELOG.md | 1 + crates/vt/src/session/cache/archive.rs | 128 ++++++++++++++++++++++++- crates/vt/src/session/cache/mod.rs | 2 +- crates/vt/src/session/execute/glob.rs | 65 +++++++++++-- 4 files changed, 183 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e2ea8f4..662a0954c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/` link, is complete ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **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)). - **Fixed** Running `vp run` in a package that sits inside a workspace folder but isn't listed in its `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` now runs that package's own tasks, instead of treating the enclosing workspace as its root. When both configurations exist, `pnpm-workspace.yaml` takes precedence ([#859](https://github.com/voidzero-dev/vite-task/pull/859)). - **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)). diff --git a/crates/vt/src/session/cache/archive.rs b/crates/vt/src/session/cache/archive.rs index dc110e29c..47512af8b 100644 --- a/crates/vt/src/session/cache/archive.rs +++ b/crates/vt/src/session/cache/archive.rs @@ -7,10 +7,13 @@ use std::{ use vt_path::{AbsolutePath, RelativePathBuf}; -/// Create a tar.zst archive from workspace-relative output file paths. +/// Create a tar.zst archive from workspace-relative output paths. /// -/// Files that no longer exist are silently skipped (the task may delete -/// temporary files during execution). +/// A regular file is stored with its contents. A symbolic link is stored as a +/// link with its target, never followed, so a link to a directory or a +/// dangling link round-trips as the task left it. Paths that no longer exist +/// are silently skipped (the task may delete temporary files during +/// execution). /// /// # Errors /// @@ -28,7 +31,7 @@ pub fn create_output_archive( let abs_path = workspace_root.join(rel_path); // Skip files that no longer exist (task may delete temp files between // glob walk and archiving). Any other error is propagated. - let metadata = match std::fs::metadata(abs_path.as_path()) { + let metadata = match std::fs::symlink_metadata(abs_path.as_path()) { Ok(m) => m, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err.into()), @@ -39,6 +42,13 @@ pub fn create_output_archive( header.set_metadata(&metadata); header.set_cksum(); builder.append_data(&mut header, rel_path.as_str(), &mut file)?; + } else if metadata.file_type().is_symlink() { + let target = std::fs::read_link(abs_path.as_path())?; + let mut header = tar::Header::new_gnu(); + header.set_metadata(&metadata); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + builder.append_link(&mut header, rel_path.as_str(), target)?; } } @@ -46,7 +56,8 @@ pub fn create_output_archive( Ok(()) } -/// Extract a tar.zst archive, restoring files relative to workspace root. +/// Extract a tar.zst archive, restoring files and symbolic links relative to +/// workspace root. /// /// Parent directories are created automatically. Existing files are overwritten. /// @@ -61,7 +72,43 @@ pub fn extract_output_archive( let decoder = zstd::Decoder::new(file)?; let mut archive = tar::Archive::new(decoder); + #[cfg(not(windows))] archive.unpack(workspace_root.as_path())?; + #[cfg(windows)] + unpack_with_directory_links(&mut archive, workspace_root)?; + Ok(()) +} + +/// [`tar::Archive::unpack`], except that a restored link whose target is a +/// directory becomes a directory link: `tar` creates every link with +/// `symlink_file` on Windows, and Windows doesn't follow a file link to a +/// directory. Links are fixed up once every entry is unpacked, so their targets +/// exist when they are checked. +#[cfg(windows)] +fn unpack_with_directory_links( + archive: &mut tar::Archive, + workspace_root: &AbsolutePath, +) -> anyhow::Result<()> { + // As `tar::Archive::unpack` does: the canonical root is a verbatim (`\\?\`) + // path, which lifts Windows' path length limit. + let root = std::fs::canonicalize(workspace_root.as_path()) + .unwrap_or_else(|_| workspace_root.as_path().to_path_buf()); + let mut links = Vec::new(); + for entry in archive.entries()? { + let mut entry = entry?; + let is_link = entry.header().entry_type().is_symlink(); + let link = root.join(entry.path()?); + if entry.unpack_in(&root)? && is_link { + links.push(link); + } + } + for link in links { + let target = std::fs::read_link(&link)?; + if link.parent().is_some_and(|parent| parent.join(&target).is_dir()) { + std::fs::remove_file(&link)?; + std::os::windows::fs::symlink_dir(&target, &link)?; + } + } Ok(()) } @@ -84,6 +131,8 @@ pub fn check_output_archive(reader: impl Read) -> io::Result<()> { #[cfg(test)] mod tests { + use std::fs; + use vt_path::AbsolutePathBuf; use super::*; @@ -103,4 +152,73 @@ mod tests { assert!(check_output_archive(corrupt).is_err()); } } + + fn symlink(target: &str, link: &AbsolutePath, directory: bool) { + #[cfg(unix)] + { + let _ = directory; + std::os::unix::fs::symlink(target, link.as_path()).unwrap(); + } + #[cfg(windows)] + if directory { + std::os::windows::fs::symlink_dir(target, link.as_path()).unwrap(); + } else { + std::os::windows::fs::symlink_file(target, link.as_path()).unwrap(); + } + } + + #[test] + fn symlinks_round_trip_as_links() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + fs::create_dir_all(dir.join("dist/pkg")).unwrap(); + fs::write(dir.join("dist/pkg/index.js"), "export default 42;").unwrap(); + let links = [ + ("file.js", "pkg/index.js", false), + ("dependency", "pkg", true), + ("dangling", "missing", false), + ]; + for (name, target, directory) in links { + symlink(target, &dir.join("dist").join(name), directory); + } + let files = ["dist/pkg/index.js", "dist/file.js", "dist/dependency", "dist/dangling"] + .map(|path| RelativePathBuf::new(path).unwrap()); + let archive_path = dir.join("output.tar.zst"); + create_output_archive(&dir, &files, &archive_path).unwrap(); + + fs::remove_dir_all(dir.join("dist")).unwrap(); + extract_output_archive(&dir, &archive_path).unwrap(); + for (name, target, _) in links { + let restored = fs::read_link(dir.join("dist").join(name)).unwrap(); + assert_eq!( + RelativePathBuf::new(restored).unwrap(), + RelativePathBuf::new(target).unwrap() + ); + } + assert_eq!(fs::read_to_string(dir.join("dist/file.js")).unwrap(), "export default 42;"); + assert_eq!( + fs::read_to_string(dir.join("dist/dependency/index.js")).unwrap(), + "export default 42;" + ); + assert!(!dir.join("dist/dangling").as_path().exists()); + } + + #[test] + fn restore_rejects_writing_through_a_link_outside_the_workspace() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + let outside = tempfile::TempDir::new().unwrap(); + let sentinel = outside.path().join("sentinel.txt"); + fs::write(&sentinel, "archived").unwrap(); + symlink(outside.path().to_str().unwrap(), &dir.join("link"), true); + let files = ["link", "link/sentinel.txt"].map(|path| RelativePathBuf::new(path).unwrap()); + let archive_path = dir.join("output.tar.zst"); + create_output_archive(&dir, &files, &archive_path).unwrap(); + fs::write(&sentinel, "must remain unchanged").unwrap(); + + let restored = dir.join("restored"); + fs::create_dir_all(&restored).unwrap(); + assert!(extract_output_archive(&restored, &archive_path).is_err()); + assert_eq!(fs::read_to_string(&sentinel).unwrap(), "must remain unchanged"); + } } diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index ab6c806f9..30536eb9d 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/glob.rs b/crates/vt/src/session/execute/glob.rs index 23808b422..1afb803e2 100644 --- a/crates/vt/src/session/execute/glob.rs +++ b/crates/vt/src/session/execute/glob.rs @@ -15,7 +15,7 @@ use wax::{ }; /// Walk positive globs (rooted at `root`), filtering with negative globs, and -/// call `for_each_file` with the absolute path of each file entry. Stripping +/// call `for_each_file` with the absolute path and type of each file or symlink. Stripping /// the root prefix (if needed) is the caller's responsibility. /// /// Walk errors for non-existent directories are skipped gracefully. Returning @@ -25,7 +25,7 @@ fn walk_glob_files( root: &AbsolutePath, positive_globs: &std::collections::BTreeSet, negative_globs: &std::collections::BTreeSet, - mut for_each_file: impl FnMut(AbsolutePathBuf) -> anyhow::Result>, + mut for_each_file: impl FnMut(AbsolutePathBuf, std::fs::FileType) -> anyhow::Result>, ) -> anyhow::Result<()> { if positive_globs.is_empty() { return Ok(()); @@ -52,13 +52,14 @@ fn walk_glob_files( return Err(io_err.into()); } }; - if !entry.file_type().is_file() { + let file_type = entry.file_type(); + if !file_type.is_file() && !file_type.is_symlink() { continue; } let Some(absolute) = AbsolutePathBuf::new(entry.path().to_path_buf()) else { continue; // wax can hand back non-absolute paths in some cases }; - if for_each_file(absolute)?.is_break() { + if for_each_file(absolute, file_type)?.is_break() { return Ok(()); } } @@ -77,7 +78,10 @@ pub fn compute_globbed_inputs( negative_globs: &std::collections::BTreeSet, ) -> anyhow::Result> { let mut result = BTreeMap::new(); - walk_glob_files(root, positive_globs, negative_globs, |absolute| { + walk_glob_files(root, positive_globs, negative_globs, |absolute, file_type| { + if !file_type.is_file() { + return Ok(ControlFlow::Continue(())); + } let Some(relative) = strip_root(root, &absolute)? else { return Ok(ControlFlow::Continue(())); // outside root }; @@ -98,7 +102,7 @@ pub fn compute_globbed_inputs( Ok(result) } -/// Collect file paths matching positive globs, filtered by negative globs. +/// Collect file and symlink paths matching positive globs, filtered by negative globs. /// /// Like [`compute_globbed_inputs`] but only collects paths (no hashing). /// Used for determining which output files to archive. @@ -108,7 +112,7 @@ pub fn collect_glob_paths( negative_globs: &std::collections::BTreeSet, ) -> anyhow::Result> { let mut result = Vec::new(); - walk_glob_files(root, positive_globs, negative_globs, |absolute| { + walk_glob_files(root, positive_globs, negative_globs, |absolute, _| { if let Some(relative) = strip_root(root, &absolute)? { result.push(relative); } @@ -445,4 +449,51 @@ mod tests { // BTreeMap naturally deduplicates by key assert_eq!(result.len(), 3); } + + fn symlink(target: &str, link: &AbsolutePath, directory: bool) { + #[cfg(unix)] + { + let _ = directory; + std::os::unix::fs::symlink(target, link.as_path()).unwrap(); + } + #[cfg(windows)] + if directory { + std::os::windows::fs::symlink_dir(target, link.as_path()).unwrap(); + } else { + std::os::windows::fs::symlink_file(target, link.as_path()).unwrap(); + } + } + + #[test] + fn output_paths_include_symlinks_without_traversing_them() { + let temp_dir = TempDir::new().unwrap(); + let workspace = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + fs::create_dir_all(workspace.join("dist")).unwrap(); + fs::create_dir_all(workspace.join("outside")).unwrap(); + fs::write(workspace.join("outside/index.js"), "export default 42;").unwrap(); + fs::write(workspace.join("dist/regular.js"), "export default 7;").unwrap(); + for (name, target, directory) in [ + ("file.js", "../outside/index.js", false), + ("dependency", "../outside", true), + ("dangling", "missing", false), + ("excluded", "../outside", true), + ] { + symlink(target, &workspace.join("dist").join(name), directory); + } + let positive: std::collections::BTreeSet = std::iter::once("dist/**".into()).collect(); + let negative: std::collections::BTreeSet = + std::iter::once("dist/excluded".into()).collect(); + + let outputs = collect_glob_paths(&workspace, &positive, &negative).unwrap(); + assert_eq!( + outputs.iter().map(|path| path.as_str()).collect::>(), + ["dist/dangling", "dist/dependency", "dist/file.js", "dist/regular.js"] + ); + // Inputs are unchanged: a link is not a globbed input. + let inputs = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); + assert_eq!( + inputs.keys().map(|path| path.as_str()).collect::>(), + ["dist/regular.js"] + ); + } } From dfa2fea298d459ab8c73c3288efc392837a5864b Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 24 Oct 2026 19:23:42 -0400 Subject: [PATCH 2/8] docs(changelog): link #885 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 662a0954c..f8db0940f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/` link, is complete ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/` link, is complete ([#885](https://github.com/voidzero-dev/vite-task/pull/885)). - **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)). - **Fixed** Running `vp run` in a package that sits inside a workspace folder but isn't listed in its `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` now runs that package's own tasks, instead of treating the enclosing workspace as its root. When both configurations exist, `pnpm-workspace.yaml` takes precedence ([#859](https://github.com/voidzero-dev/vite-task/pull/859)). - **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)). From 5a62d5733dc1a7f3f541cd481ec2272c05bad5df Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 29 Oct 2026 23:35:12 +0900 Subject: [PATCH 3/8] fix(cache): reject symlinks in output archives on Windows `tar` restores every link on Windows with `symlink_file`, which can't link a directory, and Windows doesn't resolve a relative link target with the `/` separators `tar` stores. Fail on a symbolic link when creating or restoring an output archive on Windows instead of restoring a broken link, and remove a partly written archive when creating it fails. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- crates/vt/src/session/cache/archive.rs | 122 +++++++++++------- crates/vt/src/session/execute/cache_update.rs | 7 +- 3 files changed, 81 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8db0940f..b9e3ccc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/` link, is complete ([#885](https://github.com/voidzero-dev/vite-task/pull/885)). +- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/` link, is complete. On Windows, a task whose outputs include a symbolic link isn't cached, and the run reports an error that names the link ([#885](https://github.com/voidzero-dev/vite-task/pull/885)). - **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)). - **Fixed** Running `vp run` in a package that sits inside a workspace folder but isn't listed in its `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` now runs that package's own tasks, instead of treating the enclosing workspace as its root. When both configurations exist, `pnpm-workspace.yaml` takes precedence ([#859](https://github.com/voidzero-dev/vite-task/pull/859)). - **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)). diff --git a/crates/vt/src/session/cache/archive.rs b/crates/vt/src/session/cache/archive.rs index 47512af8b..e7d8e0598 100644 --- a/crates/vt/src/session/cache/archive.rs +++ b/crates/vt/src/session/cache/archive.rs @@ -15,9 +15,14 @@ use vt_path::{AbsolutePath, RelativePathBuf}; /// are silently skipped (the task may delete temporary files during /// execution). /// +/// On Windows, a symbolic link is an error: `tar` restores every link there +/// with `symlink_file`, which can't link a directory, and Windows doesn't +/// resolve a relative link target with the `/` separators `tar` stores. +/// /// # Errors /// -/// Returns an error if creating the archive file or adding entries fails. +/// Returns an error if creating the archive file or adding entries fails, or +/// on Windows if an output is a symbolic link. pub fn create_output_archive( workspace_root: &AbsolutePath, output_files: &[RelativePathBuf], @@ -43,6 +48,11 @@ pub fn create_output_archive( header.set_cksum(); builder.append_data(&mut header, rel_path.as_str(), &mut file)?; } else if metadata.file_type().is_symlink() { + if cfg!(windows) { + anyhow::bail!( + "output `{rel_path}` is a symbolic link, which can't be cached on Windows" + ); + } let target = std::fs::read_link(abs_path.as_path())?; let mut header = tar::Header::new_gnu(); header.set_metadata(&metadata); @@ -61,9 +71,13 @@ pub fn create_output_archive( /// /// Parent directories are created automatically. Existing files are overwritten. /// +/// On Windows, a symbolic link in the archive is an error, for the reasons +/// given on [`create_output_archive`]. +/// /// # Errors /// -/// Returns an error if opening the archive or extracting entries fails. +/// Returns an error if opening the archive or extracting entries fails, or on +/// Windows if the archive holds a symbolic link. pub fn extract_output_archive( workspace_root: &AbsolutePath, archive_path: &AbsolutePath, @@ -75,17 +89,13 @@ pub fn extract_output_archive( #[cfg(not(windows))] archive.unpack(workspace_root.as_path())?; #[cfg(windows)] - unpack_with_directory_links(&mut archive, workspace_root)?; + unpack_without_symlinks(&mut archive, workspace_root)?; Ok(()) } -/// [`tar::Archive::unpack`], except that a restored link whose target is a -/// directory becomes a directory link: `tar` creates every link with -/// `symlink_file` on Windows, and Windows doesn't follow a file link to a -/// directory. Links are fixed up once every entry is unpacked, so their targets -/// exist when they are checked. +/// [`tar::Archive::unpack`], except that a symbolic link is an error. #[cfg(windows)] -fn unpack_with_directory_links( +fn unpack_without_symlinks( archive: &mut tar::Archive, workspace_root: &AbsolutePath, ) -> anyhow::Result<()> { @@ -93,21 +103,16 @@ fn unpack_with_directory_links( // path, which lifts Windows' path length limit. let root = std::fs::canonicalize(workspace_root.as_path()) .unwrap_or_else(|_| workspace_root.as_path().to_path_buf()); - let mut links = Vec::new(); for entry in archive.entries()? { let mut entry = entry?; - let is_link = entry.header().entry_type().is_symlink(); - let link = root.join(entry.path()?); - if entry.unpack_in(&root)? && is_link { - links.push(link); - } - } - for link in links { - let target = std::fs::read_link(&link)?; - if link.parent().is_some_and(|parent| parent.join(&target).is_dir()) { - std::fs::remove_file(&link)?; - std::os::windows::fs::symlink_dir(&target, &link)?; + if entry.header().entry_type().is_symlink() { + let path = entry.path()?; + let path = path.display(); + anyhow::bail!( + "the archive holds the symbolic link `{path}`, which can't be restored on Windows" + ); } + entry.unpack_in(&root)?; } Ok(()) } @@ -153,33 +158,16 @@ mod tests { } } - fn symlink(target: &str, link: &AbsolutePath, directory: bool) { - #[cfg(unix)] - { - let _ = directory; - std::os::unix::fs::symlink(target, link.as_path()).unwrap(); - } - #[cfg(windows)] - if directory { - std::os::windows::fs::symlink_dir(target, link.as_path()).unwrap(); - } else { - std::os::windows::fs::symlink_file(target, link.as_path()).unwrap(); - } - } - + #[cfg(unix)] #[test] fn symlinks_round_trip_as_links() { let tmp = tempfile::TempDir::new().unwrap(); let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); fs::create_dir_all(dir.join("dist/pkg")).unwrap(); fs::write(dir.join("dist/pkg/index.js"), "export default 42;").unwrap(); - let links = [ - ("file.js", "pkg/index.js", false), - ("dependency", "pkg", true), - ("dangling", "missing", false), - ]; - for (name, target, directory) in links { - symlink(target, &dir.join("dist").join(name), directory); + let links = [("file.js", "pkg/index.js"), ("dependency", "pkg"), ("dangling", "missing")]; + for (name, target) in links { + std::os::unix::fs::symlink(target, dir.join("dist").join(name)).unwrap(); } let files = ["dist/pkg/index.js", "dist/file.js", "dist/dependency", "dist/dangling"] .map(|path| RelativePathBuf::new(path).unwrap()); @@ -188,12 +176,9 @@ mod tests { fs::remove_dir_all(dir.join("dist")).unwrap(); extract_output_archive(&dir, &archive_path).unwrap(); - for (name, target, _) in links { + for (name, target) in links { let restored = fs::read_link(dir.join("dist").join(name)).unwrap(); - assert_eq!( - RelativePathBuf::new(restored).unwrap(), - RelativePathBuf::new(target).unwrap() - ); + assert_eq!(restored.to_str(), Some(target)); } assert_eq!(fs::read_to_string(dir.join("dist/file.js")).unwrap(), "export default 42;"); assert_eq!( @@ -203,6 +188,7 @@ mod tests { assert!(!dir.join("dist/dangling").as_path().exists()); } + #[cfg(unix)] #[test] fn restore_rejects_writing_through_a_link_outside_the_workspace() { let tmp = tempfile::TempDir::new().unwrap(); @@ -210,7 +196,7 @@ mod tests { let outside = tempfile::TempDir::new().unwrap(); let sentinel = outside.path().join("sentinel.txt"); fs::write(&sentinel, "archived").unwrap(); - symlink(outside.path().to_str().unwrap(), &dir.join("link"), true); + std::os::unix::fs::symlink(outside.path(), dir.join("link")).unwrap(); let files = ["link", "link/sentinel.txt"].map(|path| RelativePathBuf::new(path).unwrap()); let archive_path = dir.join("output.tar.zst"); create_output_archive(&dir, &files, &archive_path).unwrap(); @@ -221,4 +207,44 @@ mod tests { assert!(extract_output_archive(&restored, &archive_path).is_err()); assert_eq!(fs::read_to_string(&sentinel).unwrap(), "must remain unchanged"); } + + #[cfg(windows)] + #[test] + fn create_rejects_symlinks_on_windows() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + fs::write(dir.join("index.js"), "export default 42;").unwrap(); + std::os::windows::fs::symlink_file("index.js", dir.join("link.js")).unwrap(); + let files = ["index.js", "link.js"].map(|path| RelativePathBuf::new(path).unwrap()); + + let err = create_output_archive(&dir, &files, &dir.join("output.tar.zst")).unwrap_err(); + assert_eq!( + err.to_string(), + "output `link.js` is a symbolic link, which can't be cached on Windows" + ); + } + + #[cfg(windows)] + #[test] + fn extract_rejects_symlinks_on_windows() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + let archive_path = dir.join("output.tar.zst"); + { + let file = File::create(archive_path.as_path()).unwrap(); + let mut builder = tar::Builder::new(zstd::Encoder::new(file, 0).unwrap().auto_finish()); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + builder.append_link(&mut header, "link.js", "index.js").unwrap(); + builder.finish().unwrap(); + } + + let err = extract_output_archive(&dir, &archive_path).unwrap_err(); + assert_eq!( + err.to_string(), + "the archive holds the symbolic link `link.js`, which can't be restored on Windows" + ); + assert!(fs::symlink_metadata(dir.join("link.js")).is_err()); + } } diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index 0909839ce..d64ebddcd 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -418,7 +418,12 @@ fn collect_and_archive_outputs( let archive_name: Str = vt_str::format!("{}.tar.zst", uuid::Uuid::new_v4()); let archive_path = cache_dir.join(archive_name.as_str()); - archive::create_output_archive(workspace_root, &sorted_files, &archive_path)?; + if let Err(err) = archive::create_output_archive(workspace_root, &sorted_files, &archive_path) { + // Best-effort: no cache entry refers to the partial archive, so + // nothing else would remove it. + let _ = std::fs::remove_file(archive_path.as_path()); + return Err(err); + } Ok(Some(archive_name)) } From c9f75e231b14c4973fe43da7f05da6c10bea2894 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 29 Oct 2026 23:35:13 +0900 Subject: [PATCH 4/8] test(cache): cover symlinks restored on a cache hit end to end Add `vtt symlink` and an output_cache_test case that restores a directory link after deleting dist/, then again over the link left in place. The case is Unix-only because Windows rejects links in output archives. Co-Authored-By: Claude Opus 5.5 --- crates/vt_bin/src/vtt/main.rs | 8 ++- crates/vt_bin/src/vtt/symlink.rs | 8 +++ .../fixtures/output_cache_test/snapshots.toml | 40 ++++++++++++ ..._globs___symlinks_restored_on_cache_hit.md | 65 +++++++++++++++++++ .../fixtures/output_cache_test/vite-task.json | 7 ++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 crates/vt_bin/src/vtt/symlink.rs create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md diff --git a/crates/vt_bin/src/vtt/main.rs b/crates/vt_bin/src/vtt/main.rs index e77c719df..bd5cff2e4 100644 --- a/crates/vt_bin/src/vtt/main.rs +++ b/crates/vt_bin/src/vtt/main.rs @@ -28,6 +28,8 @@ mod small_dev_shm; mod stalled_remote_cache; mod stat_file; mod stat_many; +#[cfg(unix)] +mod symlink; mod touch_file; mod write_file; @@ -36,7 +38,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, 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, symlink, touch-file, write-file" ); std::process::exit(1); } @@ -78,6 +80,10 @@ fn main() { Ok(()) } "stat-many" => stat_many::run(&args[2..]), + #[cfg(unix)] + "symlink" => symlink::run(&args[2..]), + #[cfg(not(unix))] + "symlink" => Err("vtt symlink is only supported on Unix".into()), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vt_bin/src/vtt/symlink.rs b/crates/vt_bin/src/vtt/symlink.rs new file mode 100644 index 000000000..2ab960572 --- /dev/null +++ b/crates/vt_bin/src/vtt/symlink.rs @@ -0,0 +1,8 @@ +/// Create a symbolic link at `` that points to ``, like `ln -s`. +pub fn run(args: &[String]) -> Result<(), Box> { + let [target, link] = args else { + return Err("Usage: vtt symlink ".into()); + }; + std::os::unix::fs::symlink(target, link)?; + Ok(()) +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml index 8ba3fa56a..ff7cf0b0f 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml @@ -155,3 +155,43 @@ steps = [ "dist/skip.txt", ], comment = "skip.txt is NOT restored — it was excluded by the negative glob" }, ] + +[[e2e]] +name = "output_globs___symlinks_restored_on_cache_hit" +comment = """ +A symbolic link matched by an output glob is archived as a link, so a cache hit restores it. Restoring also replaces a link that is already there. Not run on Windows, where a symbolic link in a task's outputs can't be cached. +""" +cfg = "unix" +steps = [ + { argv = [ + "vt", + "run", + "build-with-symlink", + ], comment = "first run — cache miss, writes dist/store/pkg/index.js and links dist/pkg to store/pkg" }, + { argv = [ + "vtt", + "rm", + "-rf", + "dist", + ], comment = "delete dist/ to prove the restore is real" }, + { argv = [ + "vt", + "run", + "build-with-symlink", + ], comment = "second run — cache hit, restores the file and the link" }, + { argv = [ + "vtt", + "print-file", + "dist/pkg/index.js", + ], comment = "the file is reachable through the restored link" }, + { argv = [ + "vt", + "run", + "build-with-symlink", + ], comment = "third run — cache hit, restores over the link the second run left" }, + { argv = [ + "vtt", + "print-file", + "dist/pkg/index.js", + ], comment = "the file is still reachable through the link" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md new file mode 100644 index 000000000..3692b8575 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md @@ -0,0 +1,65 @@ +# output_globs___symlinks_restored_on_cache_hit + +A symbolic link matched by an output glob is archived as a link, so a cache hit restores it. Restoring also replaces a link that is already there. Not run on Windows, where a symbolic link in a task's outputs can't be cached. + +## `vt run build-with-symlink` + +first run — cache miss, writes dist/store/pkg/index.js and links dist/pkg to store/pkg + +``` +$ vtt write-file dist/store/pkg/index.js built + +$ vtt symlink store/pkg dist/pkg + +--- +vt run: 0/2 cache hit (0%), 2/2 successful. (Run `vt run --last-details` for full details) +``` + +## `vtt rm -rf dist` + +delete dist/ to prove the restore is real + +``` +``` + +## `vt run build-with-symlink` + +second run — cache hit, restores the file and the link + +``` +$ vtt write-file dist/store/pkg/index.js built ◉ cache hit, replaying + +$ vtt symlink store/pkg dist/pkg ◉ cache hit, replaying + +--- +vt run: 2/2 cache hit (100%), 2/2 successful. (Run `vt run --last-details` for full details) +``` + +## `vtt print-file dist/pkg/index.js` + +the file is reachable through the restored link + +``` +built +``` + +## `vt run build-with-symlink` + +third run — cache hit, restores over the link the second run left + +``` +$ vtt write-file dist/store/pkg/index.js built ◉ cache hit, replaying + +$ vtt symlink store/pkg dist/pkg ◉ cache hit, replaying + +--- +vt run: 2/2 cache hit (100%), 2/2 successful. (Run `vt run --last-details` for full details) +``` + +## `vtt print-file dist/pkg/index.js` + +the file is still reachable through the link + +``` +built +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/vite-task.json index c63d43105..d5e02b946 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/vite-task.json @@ -7,6 +7,13 @@ "output": ["dist/**"] } }, + "build-with-symlink": { + "command": "vtt write-file dist/store/pkg/index.js built && vtt symlink store/pkg dist/pkg", + "cache": { + "input": ["src/**"], + "output": ["dist/**"] + } + }, "build-with-negative": { "command": "vtt write-file dist/keep.txt keep && vtt write-file dist/skip.txt skip", "cache": { From 2a63ff9c7edc049da7a8a063d84b355dda1cc32a Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 30 Oct 2026 08:22:38 +0900 Subject: [PATCH 5/8] test(cache): cover the links in a real Nitro build's traced dependencies Add a nitro_build_cache e2e fixture that runs a real Nitro server build. Its route imports two packages that depend on different versions of `tslib` (small stand-ins committed in the fixture's node_modules), so nf3 stores each version under `.output/server/node_modules/.nf3` and links to it. After a cache hit, each package must still resolve its own version from the restored output. The case needs Node.js and Nitro, so it is ignored by default, and it is Unix-only because Windows rejects links in output archives. Add nitro to packages/tools for it. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + .../fixtures/nitro_build_cache/check.cjs | 6 + .../nitro_build_cache/nitro.config.ts | 15 ++ .../node_modules/dep-a/index.js | 1 + .../dep-a/node_modules/tslib/package.json | 5 + .../dep-a/node_modules/tslib/tslib.js | 1 + .../node_modules/dep-a/package.json | 8 + .../node_modules/dep-b/index.js | 1 + .../dep-b/node_modules/tslib/package.json | 5 + .../dep-b/node_modules/tslib/tslib.js | 1 + .../node_modules/dep-b/package.json | 8 + .../fixtures/nitro_build_cache/package.json | 5 + .../nitro_build_cache/routes/index.ts | 4 + .../fixtures/nitro_build_cache/snapshots.toml | 33 +++++ ..._build_restores_traced_dependency_links.md | 49 +++++++ .../fixtures/nitro_build_cache/vite-task.json | 11 ++ packages/tools/package.json | 1 + pnpm-lock.yaml | 137 ++++++++++++++++++ pnpm-workspace.yaml | 1 + 19 files changed, 293 insertions(+) create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/check.cjs create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/nitro.config.ts create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/index.js create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/tslib.js create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/index.js create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/tslib.js create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/routes/index.ts create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json diff --git a/.gitignore b/.gitignore index 411255e24..3e48e505d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules # Fixture node_modules that stand in for real npm installs in snapshot tests. # `**` covers nested monorepo layouts (e.g. packages/foo/node_modules/.bin). !crates/vt_plan/tests/plan_snapshots/fixtures/*/**/node_modules +!crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/**/node_modules dist .claude/settings.local.json *.tsbuildinfo diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/check.cjs b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/check.cjs new file mode 100644 index 000000000..a451f9d75 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/check.cjs @@ -0,0 +1,6 @@ +// Prints the `tslib` version each traced package resolves to in the server output. +const modules = './.output/server/node_modules/'; +for (const name of ['dep-a', 'dep-b']) { + console.log(`${name}: tslib ${require(modules + name)()}`); +} +console.log(`hoisted: tslib ${require(modules + 'tslib').version}`); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/nitro.config.ts b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/nitro.config.ts new file mode 100644 index 000000000..dbc80b94e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/nitro.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'nitro'; + +export default defineConfig({ + serverDir: './', + // Trace these packages instead of bundling them. They depend on different + // versions of `tslib`, so nf3 stores each version under `node_modules/.nf3` + // and links to it. + traceDeps: ['dep-a', 'dep-b'], + // Keep the build output stable for the snapshot: no date-dependent setting, + // no build log (`CONSOLA_LEVEL=0` in the task command quiets nf3's), and no + // bundler warnings, which Nitro prints itself. + compatibilityDate: '2026-10-29', + logLevel: 0, + rolldownConfig: { onwarn() {} }, +}); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/index.js b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/index.js new file mode 100644 index 000000000..3d5236f35 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/index.js @@ -0,0 +1 @@ +module.exports = () => require('tslib').version; diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/package.json new file mode 100644 index 000000000..a9ad8f11d --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/package.json @@ -0,0 +1,5 @@ +{ + "name": "tslib", + "version": "1.14.1", + "main": "tslib.js" +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/tslib.js b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/tslib.js new file mode 100644 index 000000000..2c8ca2498 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/node_modules/tslib/tslib.js @@ -0,0 +1 @@ +exports.version = '1.14.1'; diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/package.json new file mode 100644 index 000000000..c95b9ddfe --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-a/package.json @@ -0,0 +1,8 @@ +{ + "name": "dep-a", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "tslib": "1.14.1" + } +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/index.js b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/index.js new file mode 100644 index 000000000..3d5236f35 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/index.js @@ -0,0 +1 @@ +module.exports = () => require('tslib').version; diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/package.json new file mode 100644 index 000000000..f95741e46 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/package.json @@ -0,0 +1,5 @@ +{ + "name": "tslib", + "version": "2.8.1", + "main": "tslib.js" +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/tslib.js b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/tslib.js new file mode 100644 index 000000000..a93be49c9 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/node_modules/tslib/tslib.js @@ -0,0 +1 @@ +exports.version = '2.8.1'; diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/package.json new file mode 100644 index 000000000..7e415db1e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/node_modules/dep-b/package.json @@ -0,0 +1,8 @@ +{ + "name": "dep-b", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "tslib": "2.8.1" + } +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/package.json new file mode 100644 index 000000000..acba63e82 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/package.json @@ -0,0 +1,5 @@ +{ + "name": "nitro-build-cache-fixture", + "private": true, + "type": "module" +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/routes/index.ts b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/routes/index.ts new file mode 100644 index 000000000..5817249a6 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/routes/index.ts @@ -0,0 +1,4 @@ +import depA from 'dep-a'; +import depB from 'dep-b'; + +export default () => ({ depA: depA(), depB: depB() }); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml new file mode 100644 index 000000000..8974a8d37 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml @@ -0,0 +1,33 @@ +[[e2e]] +name = "nitro_build_restores_traced_dependency_links" +comment = """ +A real Nitro server build traces `dep-a` and `dep-b`, which depend on different versions of `tslib` (small stand-ins committed in the fixture's `node_modules`). nf3 writes each version under `.output/server/node_modules/.nf3/tslib@`, links `node_modules/tslib` to the hoisted one, and links each package's `node_modules/tslib` to the one it needs. A cache hit restores those links, so each package still resolves its own version from the restored output. Not run on Windows, where a symbolic link in a task's outputs can't be cached. +""" +cfg = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "build", + ], comment = "first run — cache miss, Nitro builds .output/" }, + { argv = [ + "node", + "check.cjs", + ], comment = "each package resolves its own tslib through nf3's links" }, + { argv = [ + "vtt", + "rm", + "-rf", + ".output", + ], comment = "delete .output/ to prove the restore is real" }, + { argv = [ + "vt", + "run", + "build", + ], comment = "second run — cache hit, restores .output/ with its links" }, + { argv = [ + "node", + "check.cjs", + ], comment = "the restored output resolves the same versions" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md new file mode 100644 index 000000000..e54dd488e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md @@ -0,0 +1,49 @@ +# nitro_build_restores_traced_dependency_links + +A real Nitro server build traces `dep-a` and `dep-b`, which depend on different versions of `tslib` (small stand-ins committed in the fixture's `node_modules`). nf3 writes each version under `.output/server/node_modules/.nf3/tslib@`, links `node_modules/tslib` to the hoisted one, and links each package's `node_modules/tslib` to the one it needs. A cache hit restores those links, so each package still resolves its own version from the restored output. Not run on Windows, where a symbolic link in a task's outputs can't be cached. + +## `vt run build` + +first run — cache miss, Nitro builds .output/ + +``` +$ CONSOLA_LEVEL=0 nitro build +``` + +## `node check.cjs` + +each package resolves its own tslib through nf3's links + +``` +dep-a: tslib 1.14.1 +dep-b: tslib 2.8.1 +hoisted: tslib 2.8.1 +``` + +## `vtt rm -rf .output` + +delete .output/ to prove the restore is real + +``` +``` + +## `vt run build` + +second run — cache hit, restores .output/ with its links + +``` +$ CONSOLA_LEVEL=0 nitro build ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `node check.cjs` + +the restored output resolves the same versions + +``` +dep-a: tslib 1.14.1 +dep-b: tslib 2.8.1 +hoisted: tslib 2.8.1 +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json new file mode 100644 index 000000000..b89b0b864 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json @@ -0,0 +1,11 @@ +{ + "tasks": { + "build": { + "command": "CONSOLA_LEVEL=0 nitro build", + "cache": { + "input": [{ "auto": true }, "!.output/**"], + "output": [{ "auto": true }, ".output/**"] + } + } + } +} diff --git a/packages/tools/package.json b/packages/tools/package.json index 851acbfee..d78218a74 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -23,6 +23,7 @@ "cross-env": "catalog:", "cross-spawn": "catalog:", "deno": "catalog:", + "nitro": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a22cf756..0ba84585f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ catalogs: deno: specifier: 2.9.6 version: 2.9.6 + nitro: + specifier: 3.0.260903-beta + version: 3.0.260903-beta oxfmt: specifier: 0.73.0 version: 0.73.0 @@ -136,6 +139,9 @@ importers: deno: specifier: 'catalog:' version: 2.9.6 + nitro: + specifier: 'catalog:' + version: 3.0.260903-beta oxfmt: specifier: 'catalog:' version: 0.73.0(vite-plus@1.1.0(@types/node@25.0.3)(@vitest/browser-playwright@5.0.3)(@vitest/ui@5.0.3)(playwright@1.63.0)(typescript@7.1.0-dev.20261001.1)) @@ -1462,6 +1468,10 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1474,6 +1484,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.4.17: + resolution: {integrity: sha512-YG2kyfafNXKmu7e6k64yrj6whrw7m5sBIsOSXOlJ/72X3TQWRQuw8DhvXFDcBk8HF77PnTSygnm7xpYCTnFpow==} + peerDependencies: + srvx: '>=0.11.5' + peerDependenciesMeta: + srvx: + optional: true + + db0@0.4.2: + resolution: {integrity: sha512-OCBz+HiE8NgfQdlvvNrzdE+4RhmoAV3QnkxOhLU40bQH/N1msRwmPBvVyXmCUDmjhDl1+t4CFcSCccmi5jf+VQ==} + deno@2.9.6: resolution: {integrity: sha512-cr16GxYiUeH1juNvFTMOxt99V5QRVmANfy5UsZvE5hjC6P/o4Op/MBbRhLeBOL8WJpV6tr9hb+Kjb1MW0nEoVg==} hasBin: true @@ -1489,6 +1510,10 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + env-runner@0.2.3: + resolution: {integrity: sha512-S8EFBKyznRwPVwayK187cYjvnpgC92xy+q0BIF++V965pfjMVVRbQ/HoTYGA/1EHmZ7E68k6Heq+4WnvVhOL5w==} + hasBin: true + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -1499,6 +1524,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + exsolve@1.2.0: + resolution: {integrity: sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1519,6 +1547,25 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + h3@2.0.8: + resolution: {integrity: sha512-ENuKlF9kC6E6PrvY1PUyE0qkrnnPTIt2/v48uyEEfTVfgoiqBCE0uwwf++2XrWwT1gxjQBDLQ8PAsmZVniKvTw==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + crossws: ^0.4.12 + ocache: '>=0.3.0' + peerDependenciesMeta: + crossws: + optional: true + ocache: + optional: true + + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + httpxy@0.5.6: + resolution: {integrity: sha512-iqc3nOxvNb5FxbhJozt8wNmHgjlmk4zzba512jMEXVwHCEBCbGAj49eGfy5PI8ts3/LFSFq8siiSNqwfY1AlfQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1615,6 +1662,14 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nf3@0.3.24: + resolution: {integrity: sha512-HxLK4bo+5jNsEETZp4w3tJblHOA9MCBY14IN9nZJJV8JDxt9yNIYxuBuLMjTAR5GFa3HL61+8VQDUrXv3/C8fw==} + + nitro@3.0.260903-beta: + resolution: {integrity: sha512-54gANPi62O8rfMvepiJUVuIzEIinYfpeHbebywlLxvVTgIYXDwSvpaU9Id+0sJOBjBx0wC1/CVYXJkH7SY+l0g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + node-forge@1.4.0: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} @@ -1623,6 +1678,9 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + ocache@0.3.1: + resolution: {integrity: sha512-qeBrhQ0ly6tgXsakgNwLx6diRUppJZIcV5OjVGa5yp9HzztutkaGDDXJqVMo1fKWz5fNDtTp3iPU2fuhHf0+Lg==} + oxfmt@0.72.0: resolution: {integrity: sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1740,6 +1798,13 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + + rou3@1.0.0: + resolution: {integrity: sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ==} + engines: {node: '>=20.19.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1756,6 +1821,11 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + srvx@1.0.8: + resolution: {integrity: sha512-ngFS/J6St4PJfHRHGjr78xkPPtbyXtdMOLNE4x/XZunEK3OhV7aGUWMHf9WTUEy6wm1U0W4okkd82ZFFWRVQTw==} + engines: {node: '>=20.16.0'} + hasBin: true + std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} @@ -1796,6 +1866,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unstorage@2.0.0-beta.0: + resolution: {integrity: sha512-VeTY+zQ3ozrNtIFBRQRuSwanHdpuRH+4t/ZzchZIVQO8/oKq2xl7fJ9c+z76tWJDW/BEXSEuzpHOxqoAl6B64A==} + vite-plus@1.1.0: resolution: {integrity: sha512-khkRlY70qqbBV0QmF9QKXSrhxIRYftb1H05AJMowMoq7iouchmAoXYeReZ8pKKMOadqrWVxFa3S9BA2m7w59iA==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -2786,6 +2862,8 @@ snapshots: commander@9.5.0: {} + consola@3.4.2: {} + convert-source-map@2.0.0: {} cross-env@10.1.0: @@ -2799,6 +2877,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.4.17(srvx@1.0.8): + optionalDependencies: + srvx: 1.0.8 + + db0@0.4.2: {} + deno@2.9.6: optionalDependencies: '@deno/darwin-arm64': 2.9.6 @@ -2814,6 +2898,13 @@ snapshots: dom-accessibility-api@0.5.16: {} + env-runner@0.2.3: + dependencies: + crossws: 0.4.17(srvx@1.0.8) + exsolve: 1.2.0 + httpxy: 0.5.6 + srvx: 1.0.8 + es-module-lexer@2.3.2: {} estree-walker@3.0.3: @@ -2822,6 +2913,8 @@ snapshots: expect-type@1.4.0: {} + exsolve@1.2.0: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -2833,6 +2926,18 @@ snapshots: fsevents@2.3.3: optional: true + h3@2.0.8(crossws@0.4.17(srvx@1.0.8))(ocache@0.3.1): + dependencies: + rou3: 1.0.0 + srvx: 1.0.8 + optionalDependencies: + crossws: 0.4.17(srvx@1.0.8) + ocache: 0.3.1 + + hookable@6.1.2: {} + + httpxy@0.5.6: {} + isexe@2.0.0: {} js-tokens@4.0.0: {} @@ -2896,10 +3001,30 @@ snapshots: nanoid@3.3.18: {} + nf3@0.3.24: {} + + nitro@3.0.260903-beta: + dependencies: + consola: 3.4.2 + crossws: 0.4.17(srvx@1.0.8) + db0: 0.4.2 + env-runner: 0.2.3 + h3: 2.0.8(crossws@0.4.17(srvx@1.0.8))(ocache@0.3.1) + hookable: 6.1.2 + nf3: 0.3.24 + ocache: 0.3.1 + rolldown: 1.2.11 + rou3: 0.9.2 + srvx: 1.0.8 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-beta.0 + node-forge@1.4.0: {} obug@2.2.1: {} + ocache@0.3.1: {} + oxfmt@0.72.0(vite-plus@1.1.0(@types/node@25.0.3)(@vitest/browser-playwright@5.0.3(@voidzero-dev/vite-plus-core@1.1.0(@types/node@25.0.3)(typescript@6.0.3))(playwright@1.63.0)(vitest@5.0.3))(@vitest/ui@5.0.3(vitest@5.0.3))(playwright@1.63.0)(typescript@6.0.3)): dependencies: tinypool: 2.2.0 @@ -3124,6 +3249,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.11 '@rolldown/binding-win32-x64-msvc': 1.2.11 + rou3@0.9.2: {} + + rou3@1.0.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3138,6 +3267,8 @@ snapshots: source-map-js@1.2.1: {} + srvx@1.0.8: {} + std-env@4.3.0: {} tinybench@6.2.0: {} @@ -3169,6 +3300,12 @@ snapshots: undici-types@7.16.0: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + unstorage@2.0.0-beta.0: {} + vite-plus@1.1.0(@types/node@25.0.3)(@vitest/browser-playwright@5.0.3(@voidzero-dev/vite-plus-core@1.1.0(@types/node@25.0.3)(typescript@6.0.3))(playwright@1.63.0)(vitest@5.0.3))(@vitest/ui@5.0.3(vitest@5.0.3))(playwright@1.63.0)(typescript@6.0.3): dependencies: '@oxc-project/types': 0.153.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6c7f35006..641226416 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,7 @@ catalog: deno: 2.9.6 husky: ^9.1.7 lint-staged: ^17.0.0 + nitro: 3.0.260903-beta oxfmt: 0.73.0 oxlint: ^1.55.0 oxlint-tsgolint: ^7.0.0 From 8522e3916c9de2fcce9a1364dc45d253e5fc079d Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 30 Oct 2026 10:12:52 +0900 Subject: [PATCH 6/8] Revert "fix(cache): reject symlinks in output archives on Windows" This reverts commit 5a62d5733dc1a7f3f541cd481ec2272c05bad5df, to check on Windows CI whether the PR's original directory-link fix-up restores the links in a real Nitro build. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- crates/vt/src/session/cache/archive.rs | 122 +++++++----------- crates/vt/src/session/execute/cache_update.rs | 7 +- 3 files changed, 50 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eba694c4..4fd869e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/` link, is complete. On Windows, a task whose outputs include a symbolic link isn't cached, and the run reports an error that names the link ([#885](https://github.com/voidzero-dev/vite-task/pull/885)). +- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/` link, is complete ([#885](https://github.com/voidzero-dev/vite-task/pull/885)). - **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 e7d8e0598..47512af8b 100644 --- a/crates/vt/src/session/cache/archive.rs +++ b/crates/vt/src/session/cache/archive.rs @@ -15,14 +15,9 @@ use vt_path::{AbsolutePath, RelativePathBuf}; /// are silently skipped (the task may delete temporary files during /// execution). /// -/// On Windows, a symbolic link is an error: `tar` restores every link there -/// with `symlink_file`, which can't link a directory, and Windows doesn't -/// resolve a relative link target with the `/` separators `tar` stores. -/// /// # Errors /// -/// Returns an error if creating the archive file or adding entries fails, or -/// on Windows if an output is a symbolic link. +/// Returns an error if creating the archive file or adding entries fails. pub fn create_output_archive( workspace_root: &AbsolutePath, output_files: &[RelativePathBuf], @@ -48,11 +43,6 @@ pub fn create_output_archive( header.set_cksum(); builder.append_data(&mut header, rel_path.as_str(), &mut file)?; } else if metadata.file_type().is_symlink() { - if cfg!(windows) { - anyhow::bail!( - "output `{rel_path}` is a symbolic link, which can't be cached on Windows" - ); - } let target = std::fs::read_link(abs_path.as_path())?; let mut header = tar::Header::new_gnu(); header.set_metadata(&metadata); @@ -71,13 +61,9 @@ pub fn create_output_archive( /// /// Parent directories are created automatically. Existing files are overwritten. /// -/// On Windows, a symbolic link in the archive is an error, for the reasons -/// given on [`create_output_archive`]. -/// /// # Errors /// -/// Returns an error if opening the archive or extracting entries fails, or on -/// Windows if the archive holds a symbolic link. +/// Returns an error if opening the archive or extracting entries fails. pub fn extract_output_archive( workspace_root: &AbsolutePath, archive_path: &AbsolutePath, @@ -89,13 +75,17 @@ pub fn extract_output_archive( #[cfg(not(windows))] archive.unpack(workspace_root.as_path())?; #[cfg(windows)] - unpack_without_symlinks(&mut archive, workspace_root)?; + unpack_with_directory_links(&mut archive, workspace_root)?; Ok(()) } -/// [`tar::Archive::unpack`], except that a symbolic link is an error. +/// [`tar::Archive::unpack`], except that a restored link whose target is a +/// directory becomes a directory link: `tar` creates every link with +/// `symlink_file` on Windows, and Windows doesn't follow a file link to a +/// directory. Links are fixed up once every entry is unpacked, so their targets +/// exist when they are checked. #[cfg(windows)] -fn unpack_without_symlinks( +fn unpack_with_directory_links( archive: &mut tar::Archive, workspace_root: &AbsolutePath, ) -> anyhow::Result<()> { @@ -103,16 +93,21 @@ fn unpack_without_symlinks( // path, which lifts Windows' path length limit. let root = std::fs::canonicalize(workspace_root.as_path()) .unwrap_or_else(|_| workspace_root.as_path().to_path_buf()); + let mut links = Vec::new(); for entry in archive.entries()? { let mut entry = entry?; - if entry.header().entry_type().is_symlink() { - let path = entry.path()?; - let path = path.display(); - anyhow::bail!( - "the archive holds the symbolic link `{path}`, which can't be restored on Windows" - ); + let is_link = entry.header().entry_type().is_symlink(); + let link = root.join(entry.path()?); + if entry.unpack_in(&root)? && is_link { + links.push(link); + } + } + for link in links { + let target = std::fs::read_link(&link)?; + if link.parent().is_some_and(|parent| parent.join(&target).is_dir()) { + std::fs::remove_file(&link)?; + std::os::windows::fs::symlink_dir(&target, &link)?; } - entry.unpack_in(&root)?; } Ok(()) } @@ -158,16 +153,33 @@ mod tests { } } - #[cfg(unix)] + fn symlink(target: &str, link: &AbsolutePath, directory: bool) { + #[cfg(unix)] + { + let _ = directory; + std::os::unix::fs::symlink(target, link.as_path()).unwrap(); + } + #[cfg(windows)] + if directory { + std::os::windows::fs::symlink_dir(target, link.as_path()).unwrap(); + } else { + std::os::windows::fs::symlink_file(target, link.as_path()).unwrap(); + } + } + #[test] fn symlinks_round_trip_as_links() { let tmp = tempfile::TempDir::new().unwrap(); let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); fs::create_dir_all(dir.join("dist/pkg")).unwrap(); fs::write(dir.join("dist/pkg/index.js"), "export default 42;").unwrap(); - let links = [("file.js", "pkg/index.js"), ("dependency", "pkg"), ("dangling", "missing")]; - for (name, target) in links { - std::os::unix::fs::symlink(target, dir.join("dist").join(name)).unwrap(); + let links = [ + ("file.js", "pkg/index.js", false), + ("dependency", "pkg", true), + ("dangling", "missing", false), + ]; + for (name, target, directory) in links { + symlink(target, &dir.join("dist").join(name), directory); } let files = ["dist/pkg/index.js", "dist/file.js", "dist/dependency", "dist/dangling"] .map(|path| RelativePathBuf::new(path).unwrap()); @@ -176,9 +188,12 @@ mod tests { fs::remove_dir_all(dir.join("dist")).unwrap(); extract_output_archive(&dir, &archive_path).unwrap(); - for (name, target) in links { + for (name, target, _) in links { let restored = fs::read_link(dir.join("dist").join(name)).unwrap(); - assert_eq!(restored.to_str(), Some(target)); + assert_eq!( + RelativePathBuf::new(restored).unwrap(), + RelativePathBuf::new(target).unwrap() + ); } assert_eq!(fs::read_to_string(dir.join("dist/file.js")).unwrap(), "export default 42;"); assert_eq!( @@ -188,7 +203,6 @@ mod tests { assert!(!dir.join("dist/dangling").as_path().exists()); } - #[cfg(unix)] #[test] fn restore_rejects_writing_through_a_link_outside_the_workspace() { let tmp = tempfile::TempDir::new().unwrap(); @@ -196,7 +210,7 @@ mod tests { let outside = tempfile::TempDir::new().unwrap(); let sentinel = outside.path().join("sentinel.txt"); fs::write(&sentinel, "archived").unwrap(); - std::os::unix::fs::symlink(outside.path(), dir.join("link")).unwrap(); + symlink(outside.path().to_str().unwrap(), &dir.join("link"), true); let files = ["link", "link/sentinel.txt"].map(|path| RelativePathBuf::new(path).unwrap()); let archive_path = dir.join("output.tar.zst"); create_output_archive(&dir, &files, &archive_path).unwrap(); @@ -207,44 +221,4 @@ mod tests { assert!(extract_output_archive(&restored, &archive_path).is_err()); assert_eq!(fs::read_to_string(&sentinel).unwrap(), "must remain unchanged"); } - - #[cfg(windows)] - #[test] - fn create_rejects_symlinks_on_windows() { - let tmp = tempfile::TempDir::new().unwrap(); - let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); - fs::write(dir.join("index.js"), "export default 42;").unwrap(); - std::os::windows::fs::symlink_file("index.js", dir.join("link.js")).unwrap(); - let files = ["index.js", "link.js"].map(|path| RelativePathBuf::new(path).unwrap()); - - let err = create_output_archive(&dir, &files, &dir.join("output.tar.zst")).unwrap_err(); - assert_eq!( - err.to_string(), - "output `link.js` is a symbolic link, which can't be cached on Windows" - ); - } - - #[cfg(windows)] - #[test] - fn extract_rejects_symlinks_on_windows() { - let tmp = tempfile::TempDir::new().unwrap(); - let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); - let archive_path = dir.join("output.tar.zst"); - { - let file = File::create(archive_path.as_path()).unwrap(); - let mut builder = tar::Builder::new(zstd::Encoder::new(file, 0).unwrap().auto_finish()); - let mut header = tar::Header::new_gnu(); - header.set_entry_type(tar::EntryType::Symlink); - header.set_size(0); - builder.append_link(&mut header, "link.js", "index.js").unwrap(); - builder.finish().unwrap(); - } - - let err = extract_output_archive(&dir, &archive_path).unwrap_err(); - assert_eq!( - err.to_string(), - "the archive holds the symbolic link `link.js`, which can't be restored on Windows" - ); - assert!(fs::symlink_metadata(dir.join("link.js")).is_err()); - } } diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index d64ebddcd..0909839ce 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -418,12 +418,7 @@ fn collect_and_archive_outputs( let archive_name: Str = vt_str::format!("{}.tar.zst", uuid::Uuid::new_v4()); let archive_path = cache_dir.join(archive_name.as_str()); - if let Err(err) = archive::create_output_archive(workspace_root, &sorted_files, &archive_path) { - // Best-effort: no cache entry refers to the partial archive, so - // nothing else would remove it. - let _ = std::fs::remove_file(archive_path.as_path()); - return Err(err); - } + archive::create_output_archive(workspace_root, &sorted_files, &archive_path)?; Ok(Some(archive_name)) } From b1ae3186a93b76ad7dbf88bc099f497780d11dfa Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 30 Oct 2026 10:14:16 +0900 Subject: [PATCH 7/8] test(cache): run the Nitro case on Windows, keep the link round trip Unix-only With links restored on Windows again, run the real Nitro build case there too: nf3 creates junctions on Windows, whose targets are absolute paths. Keep `symlinks_round_trip_as_links` and the `vtt` case Unix-only, since Windows doesn't resolve a relative link target with the `/` separators `tar` stores, and the restore can't replace an existing directory link. Co-Authored-By: Claude Opus 5.5 --- crates/vt/src/session/cache/archive.rs | 3 +++ .../e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml | 3 +-- .../snapshots/nitro_build_restores_traced_dependency_links.md | 2 +- .../e2e_snapshots/fixtures/output_cache_test/snapshots.toml | 2 +- .../snapshots/output_globs___symlinks_restored_on_cache_hit.md | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/vt/src/session/cache/archive.rs b/crates/vt/src/session/cache/archive.rs index 47512af8b..c4c0fe6e6 100644 --- a/crates/vt/src/session/cache/archive.rs +++ b/crates/vt/src/session/cache/archive.rs @@ -167,6 +167,9 @@ mod tests { } } + // Unix only: Windows doesn't resolve a relative link target with `/` + // separators, such as `pkg/index.js`, which is how `tar` stores targets. + #[cfg(unix)] #[test] fn symlinks_round_trip_as_links() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml index 8974a8d37..a5b9528c2 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml @@ -1,9 +1,8 @@ [[e2e]] name = "nitro_build_restores_traced_dependency_links" comment = """ -A real Nitro server build traces `dep-a` and `dep-b`, which depend on different versions of `tslib` (small stand-ins committed in the fixture's `node_modules`). nf3 writes each version under `.output/server/node_modules/.nf3/tslib@`, links `node_modules/tslib` to the hoisted one, and links each package's `node_modules/tslib` to the one it needs. A cache hit restores those links, so each package still resolves its own version from the restored output. Not run on Windows, where a symbolic link in a task's outputs can't be cached. +A real Nitro server build traces `dep-a` and `dep-b`, which depend on different versions of `tslib` (small stand-ins committed in the fixture's `node_modules`). nf3 writes each version under `.output/server/node_modules/.nf3/tslib@`, links `node_modules/tslib` to the hoisted one, and links each package's `node_modules/tslib` to the one it needs. A cache hit restores those links, so each package still resolves its own version from the restored output. On Windows, nf3 creates junctions, whose targets are absolute paths. """ -cfg = "unix" ignore = true steps = [ { argv = [ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md index e54dd488e..58a96b2c0 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_build_restores_traced_dependency_links.md @@ -1,6 +1,6 @@ # nitro_build_restores_traced_dependency_links -A real Nitro server build traces `dep-a` and `dep-b`, which depend on different versions of `tslib` (small stand-ins committed in the fixture's `node_modules`). nf3 writes each version under `.output/server/node_modules/.nf3/tslib@`, links `node_modules/tslib` to the hoisted one, and links each package's `node_modules/tslib` to the one it needs. A cache hit restores those links, so each package still resolves its own version from the restored output. Not run on Windows, where a symbolic link in a task's outputs can't be cached. +A real Nitro server build traces `dep-a` and `dep-b`, which depend on different versions of `tslib` (small stand-ins committed in the fixture's `node_modules`). nf3 writes each version under `.output/server/node_modules/.nf3/tslib@`, links `node_modules/tslib` to the hoisted one, and links each package's `node_modules/tslib` to the one it needs. A cache hit restores those links, so each package still resolves its own version from the restored output. On Windows, nf3 creates junctions, whose targets are absolute paths. ## `vt run build` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml index ff7cf0b0f..fd54f3af1 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml @@ -159,7 +159,7 @@ steps = [ [[e2e]] name = "output_globs___symlinks_restored_on_cache_hit" comment = """ -A symbolic link matched by an output glob is archived as a link, so a cache hit restores it. Restoring also replaces a link that is already there. Not run on Windows, where a symbolic link in a task's outputs can't be cached. +A symbolic link matched by an output glob is archived as a link, so a cache hit restores it. Restoring also replaces a link that is already there. Unix only: on Windows, `tar` restores the target `store/pkg` with its `/` separator, which Windows doesn't resolve, and can't replace the directory link already there. """ cfg = "unix" steps = [ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md index 3692b8575..a73f2c8b7 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___symlinks_restored_on_cache_hit.md @@ -1,6 +1,6 @@ # output_globs___symlinks_restored_on_cache_hit -A symbolic link matched by an output glob is archived as a link, so a cache hit restores it. Restoring also replaces a link that is already there. Not run on Windows, where a symbolic link in a task's outputs can't be cached. +A symbolic link matched by an output glob is archived as a link, so a cache hit restores it. Restoring also replaces a link that is already there. Unix only: on Windows, `tar` restores the target `store/pkg` with its `/` separator, which Windows doesn't resolve, and can't replace the directory link already there. ## `vt run build-with-symlink` From 5fb04031ed11a7093b16093a6e22a0da5b9aeeaa Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 30 Oct 2026 10:23:19 +0900 Subject: [PATCH 8/8] test(cache): use explicit globs in the Nitro case On Windows, automatic input tracking records the `.output` directory itself, which `!.output/**` doesn't exclude, so deleting `.output` turned the second run into a cache miss. List the inputs and outputs explicitly. Co-Authored-By: Claude Opus 5.5 --- .../e2e_snapshots/fixtures/nitro_build_cache/vite-task.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json index b89b0b864..f53f3a9da 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json @@ -3,8 +3,8 @@ "build": { "command": "CONSOLA_LEVEL=0 nitro build", "cache": { - "input": [{ "auto": true }, "!.output/**"], - "output": [{ "auto": true }, ".output/**"] + "input": ["routes/**", "nitro.config.ts"], + "output": [".output/**"] } } }