Skip to content

fix(fspy): don't treat opening a file as a folder as reading it - #991

Open
wan9chi wants to merge 2 commits into
mainfrom
fix/fspy-folder-open-of-file
Open

wan9chi wants to merge 2 commits into
mainfrom
fix/fspy-folder-open-of-file

Conversation

@wan9chi

@wan9chi wan9chi commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Motivation

On macOS, vp run records the files a task deletes with Node's fs.rmSync as files it read. Since Node 23, fs.rmSync is libc++'s std::filesystem::remove_all. It opens every path it removes with O_RDONLY | O_DIRECTORY | O_NOFOLLOW to tell files from folders, and unlinks the path when that open fails with ENOTDIR or ELOOP. fspy classifies every open without write access as a read, so each removed file became an inferred input, fingerprinted as the task left it.

So when a build cleans its output folder and writes it again, and a later step changes one of the outputs, for example by stamping a version into it, the next run misses with '<file>' modified, though the task never read the file. This is the macOS counterpart of #980. We hit it with a runtime build that calls rmSync on its output folder before rebuilding it, which missed on local macOS runs and hit on Linux CI. On Linux, Node's fs.rmSync is libstdc++'s, which tells files from folders by their directory entries and unlinks files without opening them.

Before Node 23, fs.rmSync lstats each path instead. A stat is still recorded as a read on every platform, so this change doesn't affect that case.

Changes

  • An O_DIRECTORY open is no longer recorded, in the preload library on macOS and Linux and in the seccomp handler for static executables on Linux, as delete-only opens on Windows aren't since fix(fspy): don't treat deleting a file as reading it on Windows #980. Such an open reads nothing: it opens only a folder, and reading the folder's entries is recorded as before, through opendir, fdopendir, scandir, getdirentries or getdents64. Both share one classification of the open flags, and the preload's ToAccessMode returns Option<AccessMode>, as on Windows.
  • An O_DIRECTORY open of a folder that the task then doesn't list no longer makes the folder an input. The accesses under the folder still are. Nor is a missing folder an input when a task lists it with an O_DIRECTORY open and no stat first, so creating that folder later doesn't make the next run miss, unless a listing of its parent or a stat of it was recorded. With the preload library, opendir and scandir still record a missing folder, but a bare Go os.ReadDir on macOS doesn't. With the seccomp handler, which tracks static executables and every process on musl, opendir itself opens with O_DIRECTORY, so no listing of a missing folder is recorded there, Node's readdir on Alpine included.
  • On Linux, O_TMPFILE includes O_DIRECTORY, so that open isn't recorded either. It recorded a write of the folder, which vt ignores, and the linkat that gives the file a name is recorded as a write.
  • On Linux and macOS, vtt fs rm now removes each path the way libc++ does, opening it as a folder first. That way, fix(fspy): don't treat deleting a file as reading it on Windows #980's output_changed_after_clean_and_rebuild case covers this change. Before this change, it failed on macOS and Linux with cache miss: 'out/a.txt' modified. On Windows, vtt fs rm still uses remove_dir_all, which, like the MSVC STL's remove_all, deletes each path with a delete-only open, which fix(fspy): don't treat deleting a file as reading it on Windows #980 stopped recording.
  • New fspy tests rust_std::open_directory (preload) and static_executable::open_directory (seccomp) check that an O_DIRECTORY open isn't recorded.

🤖 Generated with Claude Code


Note

Medium Risk
Changes core file-access classification used for task cache keys; behavior is narrow and well-tested, but wrong classification would still cause false cache hits or misses.

Overview
Stops treating O_DIRECTORY opens as input reads in Unix file tracking (preload and seccomp), via shared open_access_mode in fspy_shared_unix. When flags include O_DIRECTORY, tracking records nothing; directory listing still comes from readdir/opendir paths as before.

This fixes incorrect cache misses on macOS when tasks delete outputs with Node 23+ fs.rmSync (libc++ remove_all probes each path with O_DIRECTORY first). The CHANGELOG fix now covers Windows and macOS together with Linux-aligned behavior.

ToAccessMode::to_access_mode now returns Option<AccessMode>; callers skip IPC when the mode is None. vtt fs rm on Unix mirrors the same delete semantics (try directory open, then remove file) so integration tests match production. New open_directory tests cover preload and static seccomp binaries.

Reviewed by Cursor Bugbot for commit 21fa937. Bugbot is set up for automated code reviews on this repo. Configure here.

Since Node 23, Node's `fs.rmSync` on macOS is libc++'s
`std::filesystem::remove_all`, which opens every path it removes with
`O_DIRECTORY | O_NOFOLLOW` and unlinks the path when that open fails
with `ENOTDIR` or `ELOOP`. fspy recorded the failed open as a read, so a
task that cleans and rebuilds its output folder had each output that
existed beforehand as an input, and missed the cache once a later step
changed one.

An `O_DIRECTORY` open for reading, without `O_CREAT`, of a path that is
there and isn't a folder is no longer recorded, in the preload library
and in the Linux seccomp handler. An open of a folder, or of a path that
isn't there, is still recorded, since tools such as Go's `os.ReadDir`
list folders that way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

fspy benchmark

linux

dynamic/launch             change  +0.19%  [ -3.87% ..  +4.28%]  overhead   +64.29%
dynamic/access             change  +0.04%  [ -5.46% ..  +7.44%]  overhead   +21.96%
dynamic/access-relative    change  +0.55%  [ -7.01% ..  +6.06%]  overhead   +72.09%
dynamic/access-contended   change  +1.88%  [ -4.89% .. +12.30%]  overhead   +12.86%
static/launch              change  -0.12%  [ -3.56% ..  +3.51%]  overhead  +198.78%
static/access              change  -0.23%  [ -2.05% ..  +1.26%]  overhead +1436.18%
static/access-relative     change  -0.04%  [ -2.20% ..  +3.30%]  overhead +1977.53%
static/access-contended    change  +0.34%  [ -2.43% ..  +1.86%]  overhead +2046.69%

macos

dynamic/launch             change  -0.07%  [ -4.33% ..  +3.56%]  overhead  +239.80%
dynamic/access             change  +0.33%  [ -3.13% ..  +3.20%]  overhead    +4.76%
dynamic/access-relative    change  +4.11%  [ -8.54% .. +30.93%]  overhead  +240.99%
dynamic/access-contended   change  +1.81%  [ -6.47% .. +11.55%]  overhead    +2.42%

windows

dynamic/launch             change  +0.27%  [ -0.79% ..  +1.65%]  overhead   +31.12%
dynamic/access             change  +0.00%  [ -0.43% ..  +0.43%]  overhead    +1.08%
dynamic/access-relative    change  +0.00%  [ -0.42% ..  +0.42%]  overhead    +1.28%
dynamic/access-contended   change  +0.00%  [ -0.41% ..  +0.41%]  overhead    +1.04%

Drop the fstatat check: an O_DIRECTORY open reads nothing, whether it
opens a folder or fails on a file, and listing the folder is recorded on
its own.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant