Describe the bug
When a cached vp build task empties its output directory before writing (Vite's default emptyOutDir: true), Node's recursive remove probes each existing output file with openat(path, O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY), which fails with ENOTDIR on a regular file and is followed by unlink. The task-cache fs tracker counts that failed O_RDONLY probe as a content read. Because the build then writes a fresh same-named file (a manifest, index.html, a copied static asset) to that path, the task is flagged as having both read and written it → modified its input → never cached.
This is the same class as the already-fixed #1187 / #1095 ("output files in input scope → task modifies its own input"). Those were fixed by excluding the default output dir from input tracking, which is why a default dist/ build caches fine today. The bug here is the residual case that exclusion does not reach: when outDir is a custom path nested under a directory that is itself tracked input — e.g. laravel-vite-plugin's build.outDir = 'public/build', nested under public/ whose static assets the build reads and copies — the delete-probe under that path is still counted, and the build is uncacheable.
Minimal standalone reproduction (no Laravel, no framework)
Public repo: https://github.com/gsmeira/vite-plus-cache-repro (pnpm install, then the steps below). Two sibling apps with identical source; only build.outDir differs:
// apps/control/vite.config.ts — default outDir → caches
export default defineConfig({
build: { manifest: "manifest.json", emptyOutDir: true }, // outDir defaults to dist/
run: { tasks: { build: { command: "vp build", cache: true } } },
})
// apps/bug/vite.config.ts — outDir nested under the tracked public/
export default defineConfig({
build: { outDir: "public/build", manifest: "manifest.json", emptyOutDir: true },
run: { tasks: { build: { command: "vp build", cache: true } } },
})
public/ is Vite's default publicDir; its static asset (public/robots.txt) is read and copied by the build, so the tracker treats public/ as input scope and public/build/ (the outDir) is nested inside it.
Run for each app (build #2 executes over build #1's output, which is what arms emptyOutDir's delete):
vp cache clean && vp run --filter <app> build # build #1 — writes output
vp cache clean && vp run --filter <app> build # build #2 — executes over prior output, repopulates cache
vp run --filter <app> build # build #3 — should be a cache hit
vp run --last-details
Observed on vite-plus 0.2.1:
| App |
outDir |
build #3 |
control |
dist/ (default) |
100% cache hit |
bug |
public/build/ |
0% — Not cached: read and wrote '…/public/build/<file>' |
The named file varies run to run (whichever stable-named output under public/build/ the tracker counts first — manifest.json, index.html, or a copied public/* asset); strace -f -e trace=%file confirms the manifest receives the identical probe:
lstat(".../public/build/manifest.json") = 0
openat(".../public/build/manifest.json", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = -1 ENOTDIR
unlink(".../public/build/manifest.json") = 0
# ...later, in a worker thread, the fresh write:
openat(".../public/build/manifest.json", O_WRONLY|O_CREAT|O_TRUNC) = 29
So the trigger is purely structural — a stable-named output written into an outDir nested under a tracked input dir — and is independent of Laravel; public/build is just the most common instance of that shape.
What actually fixes it (fix localized to input-side exclusion)
Tested on 0.2.1, website-laravel, building over prior output:
| Task config |
Result |
| baseline (the bug) |
0% — read and wrote public/build/… |
exclude the outDir from input: input: [{ auto: true }, "!public/build/**"] |
✅ 100% cache hit |
declare output: output: ["public/build/**"] |
❌ 0% — read and wrote public/build/… |
So the cure is input-side exclusion of the output dir, not output declaration. Two further non-invasive confirmations the delete-probe is the sole cause: with no prior output (rm -rf public/build first) the build caches; and build.emptyOutDir: false (no recursive delete → no probe) makes it cache.
Relationship to existing work
Impact
Any cacheable build that empties an output directory located under a tracked path before writing stable-named files (Laravel public/build/manifest.json, SSR bootstrap/ssr/ssr-manifest.json, copied public assets) is spuriously uncacheable on every incremental rebuild where prior output exists — i.e. exactly the warm-cache case the task cache exists to accelerate. Fresh checkouts / CI (no prior output) are unaffected.
Environment: vp 0.1.12; vite-plus 0.2.1 (vite-plus-core 0.2.1, vite 8.0.16, rolldown 1.1.1); Linux. fs tracker = fspy.
Reproduction
https://github.com/gsmeira/vite-plus-cache-repro
Steps to reproduce
Public minimal repo (verified from a clean git clone + pnpm install --frozen-lockfile): two identical apps differing only in build.outDir — apps/control (dist/, caches 100%) vs apps/bug (public/build/, 0% — "read and wrote …/public/build/…").
pnpm install
# bug — never caches:
vp cache clean && vp run --filter bug build # build #1 (writes output)
vp cache clean && vp run --filter bug build # build #2 (executes over prior output, repopulates cache)
vp run --filter bug build && vp run --last-details # -> 0%, "read and wrote …/public/build/…"
# control — caches (same steps, --filter control) -> 100% cache hit
System Info
$ vp --version
vp v0.1.12
Local vite-plus:
vite-plus v0.2.1
Tools:
vite v8.0.16
rolldown v1.1.1
vitest v4.1.8
oxfmt v0.55.0
oxlint v1.70.0
tsdown v0.22.3
Environment:
Package manager pnpm v11.8.0
Node.js v>=22.12.0 (engines.node)
$ vp env current
Environment:
Version 24.17.0
Source engines.node
Project Root <repro root>
Used Package Manager
pnpm
Logs
Validations
Describe the bug
When a cached
vp buildtask empties its output directory before writing (Vite's defaultemptyOutDir: true), Node's recursive remove probes each existing output file withopenat(path, O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY), which fails withENOTDIRon a regular file and is followed byunlink. The task-cache fs tracker counts that failedO_RDONLYprobe as a content read. Because the build then writes a fresh same-named file (a manifest,index.html, a copied static asset) to that path, the task is flagged as having both read and written it →modified its input→ never cached.This is the same class as the already-fixed #1187 / #1095 ("output files in input scope → task modifies its own input"). Those were fixed by excluding the default output dir from input tracking, which is why a default
dist/build caches fine today. The bug here is the residual case that exclusion does not reach: whenoutDiris a custom path nested under a directory that is itself tracked input — e.g.laravel-vite-plugin'sbuild.outDir = 'public/build', nested underpublic/whose static assets the build reads and copies — the delete-probe under that path is still counted, and the build is uncacheable.Minimal standalone reproduction (no Laravel, no framework)
Public repo: https://github.com/gsmeira/vite-plus-cache-repro (
pnpm install, then the steps below). Two sibling apps with identical source; onlybuild.outDirdiffers:public/is Vite's defaultpublicDir; its static asset (public/robots.txt) is read and copied by the build, so the tracker treatspublic/as input scope andpublic/build/(the outDir) is nested inside it.Run for each app (build #2 executes over build #1's output, which is what arms
emptyOutDir's delete):Observed on
vite-plus 0.2.1:controldist/(default)bugpublic/build/Not cached: read and wrote '…/public/build/<file>'The named file varies run to run (whichever stable-named output under
public/build/the tracker counts first —manifest.json,index.html, or a copiedpublic/*asset);strace -f -e trace=%fileconfirms the manifest receives the identical probe:So the trigger is purely structural — a stable-named output written into an
outDirnested under a tracked input dir — and is independent of Laravel;public/buildis just the most common instance of that shape.What actually fixes it (fix localized to input-side exclusion)
Tested on
0.2.1,website-laravel, building over prior output:read and wrote public/build/…input: [{ auto: true }, "!public/build/**"]output: ["public/build/**"]read and wrote public/build/…So the cure is input-side exclusion of the output dir, not output declaration. Two further non-invasive confirmations the delete-probe is the sole cause: with no prior output (
rm -rf public/buildfirst) the build caches; andbuild.emptyOutDir: false(no recursive delete → no probe) makes it cache.Relationship to existing work
outDirnested under a tracked directory.ignoreInput/ignoreOutput. ThatignoreInputchannel is exactly the input-side exclusion shown above to fix this, so feat: integrate vite-task runner-aware tools (auto output + tracked envs) #1774 very likely resolves this — could a maintainer confirm whether it covers a customoutDirnested under a tracked dir (not just the defaultdist/)? If so this can ride on feat: integrate vite-task runner-aware tools (auto output + tracked envs) #1774; if not, the targeted fix below stands on its own.Impact
Any cacheable build that empties an output directory located under a tracked path before writing stable-named files (Laravel
public/build/manifest.json, SSRbootstrap/ssr/ssr-manifest.json, copied public assets) is spuriously uncacheable on every incremental rebuild where prior output exists — i.e. exactly the warm-cache case the task cache exists to accelerate. Fresh checkouts / CI (no prior output) are unaffected.Environment:
vp 0.1.12;vite-plus 0.2.1(vite-plus-core 0.2.1,vite 8.0.16,rolldown 1.1.1); Linux. fs tracker = fspy.Reproduction
https://github.com/gsmeira/vite-plus-cache-repro
Steps to reproduce
Public minimal repo (verified from a clean
git clone+pnpm install --frozen-lockfile): two identical apps differing only inbuild.outDir—apps/control(dist/, caches 100%) vsapps/bug(public/build/, 0% — "read and wrote …/public/build/…").System Info
Used Package Manager
pnpm
Logs
Validations