Skip to content

Commit 5cdc29e

Browse files
committed
feat(cache): let a hit rebuild empty folders the task made
A task that makes a folder and leaves it empty, like `astro build` making `.astro/collections`, pinned the folder holding it as an input: fspy didn't record a mkdir as one (on Windows it was a read), so the empty folder wasn't rebuildable, and the archive held only files, so a hit couldn't recreate it. Removing the folder, as on a clean checkout, was a cache miss. fspy now records the path of a mkdir as a mkdir, a new access kind, before the call, so a failed call, like one finding the folder there already, is recorded too: - Unix preload: `mkdir` and `mkdirat`. - Linux seccomp supervisor, for static binaries: `mkdirat`, plus `mkdir` on x86_64. - Windows: `NtCreateFile` with `FILE_DIRECTORY_FILE` and `FILE_CREATE` or `FILE_OPEN_IF`, where `CreateDirectoryW` ends. These opens were recorded as reads; a mkdir isn't one on Unix either. `Rebuildable` takes the mkdirs that are empty folders after the run (`.DS_Store` aside) as rebuildable folders and climbs from them too, so a folder holding only such folders, rebuildable folders and files the task wrote is rebuildable. Each folder is listed once: a mkdir'd folder above a written path is decided while climbing, where an empty one counts as rebuildable, and only the other mkdirs are checked for being empty. A run without inferred reads skips the climb and checks every mkdir, for the archive. The archive gets directory entries for the empty folders, filtered by output exclusions and `ignoreOutput` like files: all of them with automatic output tracking, and otherwise those an `output` glob matches, since the glob walk yields files only. Restoring recreates them. A folder holding anything needs no entry, since what it holds recreates it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e0b6d5f commit 5cdc29e

21 files changed

Lines changed: 467 additions & 150 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Fixed** A cache hit now restores the empty folders a task made if they're among its outputs, like the `.astro/collections` folder that `astro build` leaves empty: with automatic output tracking that's every such folder, and with `output` globs, the ones they match. A folder holding only such folders and files the task wrote, like `.astro`, may now be missing, as on a fresh clone, and the next run is still a cache hit, which restores it if what it holds is among the task's outputs. Previously a hit left empty folders missing, and removing a folder holding one was a cache miss ([#917](https://github.com/voidzero-dev/vite-task/pull/917)).
34
- **Fixed** A task's automatically tracked outputs now include the files it renames into place, like Vitest's `fsModuleCache` entries written under a temporary name, every file in a folder it renames into place, like Vite's dependency optimizer renaming `deps_temp_*` to `deps`, and the hard links it creates. The next run is a cache hit if they are missing, as on a fresh clone, and the hit restores them if they're among the task's outputs. Previously they weren't restored, and removing one that the task read back was a cache miss ([#916](https://github.com/voidzero-dev/vite-task/pull/916)).
45
- **Changed** When a task lists a folder, the files it wrote there and the folders holding only such files no longer count as entries of the listing: the next run is a cache hit if they are missing, as on a fresh clone, and the hit restores them if they're among the task's outputs. For example, `tsc` with no `include` lists the package root, which holds its `lib` output folder; removing `lib` used to be a cache miss. The folder's other entries still count ([#915](https://github.com/voidzero-dev/vite-task/pull/915)).
56
- **Changed** A task that checks or lists a folder holding only files it wrote, like `tsc` checking its `dist` output folder, is now a cache hit when the folder is missing, as on a fresh clone, and the hit restores the folder if its files are among the task's outputs. Previously the missing folder was a cache miss ([#914](https://github.com/voidzero-dev/vite-task/pull/914)).

‎crates/fspy/src/unix/syscall_handler/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ impl_handler!(
105105
faccessat,
106106
faccessat2,
107107

108+
#[cfg(target_arch = "x86_64")] mkdir,
109+
mkdirat,
108110
#[cfg(target_arch = "x86_64")] rename,
109111
renameat,
110112
renameat2,

‎crates/fspy/src/unix/syscall_handler/namespace.rs‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
//! Syscalls that put an existing file or folder at a new path without opening
2-
//! it: rename and hard link. The new path of a rename is recorded as a rename
3-
//! target, which may be a whole folder, and that of a hard link, always a
4-
//! file, as a write. The source of a rename needs no record.
1+
//! Syscalls that make an entry at a path without opening it: mkdir, and
2+
//! rename and hard link, which put an existing file or folder at a new path.
3+
//! The path of a mkdir is recorded as a mkdir, whether or not the folder is
4+
//! there already; the new path of a rename as a rename target, which may be
5+
//! a whole folder; and that of a hard link, always a file, as a write. The
6+
//! source of a rename needs no record.
57
68
use std::io;
79

@@ -11,6 +13,19 @@ use fspy_shared::ipc::AccessMode;
1113
use super::SyscallHandler;
1214

1315
impl SyscallHandler {
16+
#[cfg(target_arch = "x86_64")]
17+
pub(super) fn mkdir(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> {
18+
self.handle_path(caller, Fd::cwd(), path, AccessMode::Mkdir)
19+
}
20+
21+
pub(super) fn mkdirat(
22+
&mut self,
23+
caller: Caller,
24+
(dir_fd, path): (Fd, CStrPtr),
25+
) -> io::Result<()> {
26+
self.handle_path(caller, dir_fd, path, AccessMode::Mkdir)
27+
}
28+
1429
#[cfg(target_arch = "x86_64")]
1530
pub(super) fn rename(
1631
&mut self,

‎crates/fspy/tests/rust_std.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,24 @@ async fn renamex_np_destination() -> anyhow::Result<()> {
138138
Ok(())
139139
}
140140

141+
#[test(tokio::test)]
142+
async fn mkdir() -> anyhow::Result<()> {
143+
let tmp_dir = tempfile::tempdir()?;
144+
fs::create_dir(tmp_dir.path().join("existing"))?;
145+
let tmp_dir_str = tmp_dir.path().to_str().unwrap().to_owned();
146+
let accesses = track_fn!(tmp_dir_str, |tmp_dir_str: String| {
147+
let tmp_dir = std::path::Path::new(&tmp_dir_str);
148+
fs::create_dir(tmp_dir.join("new")).unwrap();
149+
// Fails, since the folder is there already; recorded anyway.
150+
fs::create_dir(tmp_dir.join("existing")).unwrap_err();
151+
})
152+
.await?;
153+
assert_contains(&accesses, tmp_dir.path().join("new").as_path(), PathAccess::Mkdir);
154+
assert_contains(&accesses, tmp_dir.path().join("existing").as_path(), PathAccess::Mkdir);
155+
156+
Ok(())
157+
}
158+
141159
#[test(tokio::test)]
142160
async fn metadata() -> anyhow::Result<()> {
143161
let tmp_dir = tempfile::tempdir()?;

‎crates/fspy/tests/static_executable.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ async fn openat2_readwrite() {
8989
assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::ReadWrite);
9090
}
9191

92+
#[test(tokio::test)]
93+
async fn mkdir() {
94+
// The mkdir fails, since `/hello` doesn't exist; the folder is recorded
95+
// anyway.
96+
let accesses = track_test_bin(&["mkdir", "/hello/world"], None).await;
97+
assert_contains(&accesses, Path::new("/hello/world"), fspy::PathAccess::Mkdir);
98+
}
99+
92100
#[test(tokio::test)]
93101
async fn rename_destination() {
94102
// The rename fails, since `/hello` doesn't exist; the destination is

‎crates/fspy_e2e/src/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ impl AccessCollector {
5252
PathAccess::RenameTarget(path) => {
5353
relative(path).map(|path| format!("{path}: RenameTarget"))
5454
}
55+
PathAccess::Mkdir(path) => relative(path).map(|path| format!("{path}: Mkdir")),
5556
};
5657
self.accesses.extend(line);
5758
}

‎crates/fspy_preload_unix/src/interceptions/namespace.rs‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1-
//! Calls that put an existing file or folder at a new path without opening
2-
//! it: rename and hard link. The new path is recorded before the call, so a
3-
//! failed call is recorded too: as a rename target for a rename, which may
4-
//! move a whole folder, and as a write for a hard link, always to a file. The
5-
//! source of a rename needs no record.
1+
//! Calls that make an entry at a path without opening it: mkdir, and rename
2+
//! and hard link, which put an existing file or folder at a new path. The
3+
//! path is recorded before the call, so a failed call is recorded too: as a
4+
//! mkdir for a mkdir, whose folder may be there already; as a rename target
5+
//! for a rename, which may move a whole folder; and as a write for a hard
6+
//! link, always to a file. The source of a rename needs no record.
67
78
use fspy_shared::ipc::AccessMode;
8-
use libc::{c_char, c_int};
9+
use libc::{c_char, c_int, mode_t};
910

1011
use crate::{
1112
client::{convert::PathAt, handle_open},
1213
macros::intercept,
1314
};
1415

16+
intercept!(mkdir: unsafe extern "C" fn(path: *const c_char, mode: mode_t) -> c_int);
17+
unsafe extern "C" fn mkdir(path: *const c_char, mode: mode_t) -> c_int {
18+
// SAFETY: path is a valid C string pointer provided by the caller of the interposed function
19+
unsafe { handle_open(fspy_nostd::CStr::from_ptr(path.cast()), AccessMode::Mkdir) };
20+
// SAFETY: calling the original libc mkdir() with the same arguments forwarded from the interposed function
21+
unsafe { mkdir::original()(path, mode) }
22+
}
23+
24+
intercept!(mkdirat: unsafe extern "C" fn(dirfd: c_int, path: *const c_char, mode: mode_t) -> c_int);
25+
unsafe extern "C" fn mkdirat(dirfd: c_int, path: *const c_char, mode: mode_t) -> c_int {
26+
// SAFETY: dirfd is -1 or a valid file descriptor, and path is a valid C string, provided by the caller of the interposed function
27+
unsafe { handle_open(PathAt::borrow_raw(dirfd, path), AccessMode::Mkdir) };
28+
// SAFETY: calling the original libc mkdirat() with the same arguments forwarded from the interposed function
29+
unsafe { mkdirat::original()(dirfd, path, mode) }
30+
}
31+
1532
intercept!(rename: unsafe extern "C" fn(old: *const c_char, new: *const c_char) -> c_int);
1633
unsafe extern "C" fn rename(old: *const c_char, new: *const c_char) -> c_int {
1734
// SAFETY: new is a valid C string pointer provided by the caller of the interposed function

‎crates/fspy_preload_windows/src/windows/detours/nt.rs‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ use std::mem::{offset_of, size_of, size_of_val};
33
use fspy_shared::ipc::{AccessMode, IpcPath, PathAccess};
44
use ntapi::{
55
ntioapi::{
6-
FILE_INFORMATION_CLASS, FILE_RENAME_INFORMATION, FileLinkInformation,
7-
FileLinkInformationEx, FileRenameInformation, FileRenameInformationEx,
8-
NtQueryDirectoryFile, NtQueryFullAttributesFile, NtQueryInformationByName,
9-
PFILE_BASIC_INFORMATION, PFILE_NETWORK_OPEN_INFORMATION, PIO_APC_ROUTINE, PIO_STATUS_BLOCK,
6+
FILE_CREATE, FILE_DIRECTORY_FILE, FILE_INFORMATION_CLASS, FILE_OPEN_IF,
7+
FILE_RENAME_INFORMATION, FileLinkInformation, FileLinkInformationEx, FileRenameInformation,
8+
FileRenameInformationEx, NtQueryDirectoryFile, NtQueryFullAttributesFile,
9+
NtQueryInformationByName, PFILE_BASIC_INFORMATION, PFILE_NETWORK_OPEN_INFORMATION,
10+
PIO_APC_ROUTINE, PIO_STATUS_BLOCK,
1011
},
1112
ntpsapi::{
1213
NtCreateUserProcess, PPS_ATTRIBUTE_LIST, PPS_CREATE_INFO, PS_ATTRIBUTE,
@@ -161,6 +162,11 @@ unsafe fn read_process_image_attribute<'a>(
161162
None
162163
}
163164

165+
// An open that may create a directory, from `CreateDirectoryW` (`FILE_CREATE`)
166+
// or a caller making it if it's missing (`FILE_OPEN_IF`), is recorded as a
167+
// mkdir, like `mkdir` on Unix, rather than as the read its
168+
// `FILE_LIST_DIRECTORY` access maps to. Listing the directory is recorded by
169+
// `NtQueryDirectoryFile`.
164170
static DETOUR_NT_CREATE_FILE: Detour<
165171
unsafe extern "system" fn(
166172
file_handle: PHANDLE,
@@ -192,8 +198,15 @@ static DETOUR_NT_CREATE_FILE: Detour<
192198
ea_buffer: PVOID,
193199
ea_length: ULONG,
194200
) -> HFILE {
195-
// SAFETY: intercepting file open to record access before forwarding to real function
196-
unsafe { handle_open(desired_access, object_attributes) };
201+
if create_options & FILE_DIRECTORY_FILE != 0
202+
&& matches!(create_disposition, FILE_CREATE | FILE_OPEN_IF)
203+
{
204+
// SAFETY: intercepting directory creation to record it before forwarding to real function
205+
unsafe { handle_open(AccessMode::Mkdir, object_attributes) };
206+
} else {
207+
// SAFETY: intercepting file open to record access before forwarding to real function
208+
unsafe { handle_open(desired_access, object_attributes) };
209+
}
197210

198211
// SAFETY: calling the original NtCreateFile with all original arguments
199212
unsafe {

‎crates/fspy_shared/src/ipc/mod.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub enum AccessMode {
2121
ReadWrite,
2222
ReadDir,
2323
RenameTarget,
24+
Mkdir,
2425
}
2526

2627
impl AccessMode {
@@ -32,6 +33,7 @@ impl AccessMode {
3233
Self::ReadWrite => PathAccess::ReadWrite(path),
3334
Self::ReadDir => PathAccess::ReadDir(path),
3435
Self::RenameTarget => PathAccess::RenameTarget(path),
36+
Self::Mkdir => PathAccess::Mkdir(path),
3537
}
3638
}
3739
}
@@ -48,6 +50,9 @@ pub enum PathAccess<'a> {
4850
/// The new path of a rename. What is there after the call, a file or a
4951
/// whole folder, was put there without being opened.
5052
RenameTarget(&'a IpcPath),
53+
/// A folder made by `mkdir`, recorded whether or not the call succeeded,
54+
/// so the folder may have been there already.
55+
Mkdir(&'a IpcPath),
5156
}
5257

5358
impl<'a> PathAccess<'a> {
@@ -58,7 +63,8 @@ impl<'a> PathAccess<'a> {
5863
| PathAccess::Write(path)
5964
| PathAccess::ReadWrite(path)
6065
| PathAccess::ReadDir(path)
61-
| PathAccess::RenameTarget(path) => iter::once(path),
66+
| PathAccess::RenameTarget(path)
67+
| PathAccess::Mkdir(path) => iter::once(path),
6268
}
6369
}
6470

@@ -70,6 +76,7 @@ impl<'a> PathAccess<'a> {
7076
PathAccess::ReadWrite(path) => PathAccess::ReadWrite(path.clone_in(bump)),
7177
PathAccess::ReadDir(path) => PathAccess::ReadDir(path.clone_in(bump)),
7278
PathAccess::RenameTarget(path) => PathAccess::RenameTarget(path.clone_in(bump)),
79+
PathAccess::Mkdir(path) => PathAccess::Mkdir(path.clone_in(bump)),
7380
}
7481
}
7582
}

‎crates/fspy_test_bin/src/main.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ fn main() {
4848
"execve" => {
4949
let _ = std::process::Command::new(path).spawn();
5050
}
51+
"mkdir" => {
52+
let _ = std::fs::create_dir(path);
53+
}
5154
"rename" => {
5255
let _ = std::fs::rename(path, second_path());
5356
}

0 commit comments

Comments
 (0)