Skip to content

Task cache: a build with outDir under a tracked dir (e.g. Laravel public/build) never caches — emptyOutDir delete-probe counted as a read #1894

@gsmeira

Description

@gsmeira

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.outDirapps/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

Metadata

Metadata

Assignees

No one assigned

    Type

    Priority

    None yet

    Effort

    None yet

    Target date

    None yet

    Start date

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions