Skip to content
How it works

Build scripts

How Kache caches a build script's run, and when it does not

Compiling a build script and running it are separate operations. Caching its binary alone does not reuse the run: on a fresh target directory Cargo would still execute it. On Linux and macOS, Kache also caches eligible runs. A script that compiles C sources through the cc crate pays for that even when every object is a cache hit: cc polls its compiler children and sleeps between polls, so each object costs about 100 ms per parallel lane regardless of how fast the wrapper answers.

Kache therefore caches the run itself. After the rustc wrapper produces a build_script_* binary, it preserves the binary as <name>.kache-real beside the path Cargo expects and installs a launcher there. Cargo runs the launcher, which hands the invocation to Kache.

The launcher is a small native program that ships inside Kache. It passes Cargo's environment on unchanged, including DEP_* variables whose names a shell would drop: Cargo spells a links dependency's metadata keys as the script printed them, so Tauri's cargo:core:window__... arrives as DEP_TAURI_CORE:WINDOW__....

In v2 the launcher runs a copy of the engine pinned in the profile directory under .kache-build-script-shims/, cloned where the filesystem supports it and copied otherwise. Each profile has its own executable inode; installation replaces existing pins that still share an inode. After an upgrade, existing launchers keep running the copy they were installed with until their script is rebuilt or the target directory is cleaned. If that copy is gone, the launcher runs the script directly, uncached.

Launcher installation is serialized within each build-script directory. If its lock is unavailable, Kache leaves the compiler's executable in place and skips installation. A launcher found where the preserved script should be stops with an error, including when the pinned Kache is missing.

Build-script executables are snapshotted before cache publication. Kache refuses to store launcher bytes as a compiler result. The build-script compilation key also changes once to retire older entries that could contain a launcher. Other Rust compilation keys keep their existing namespace.

If a target already contains an invalid preserved script, see Repair an invalid build-script launcher.

When Kache seeds a new target from another checkout, it copies each seeded launcher's pinned executable before copying the unit. The pin uses a filesystem clone or byte copy, so the new profile owns its executable inode and continues working after the donor target is removed. Only pins referenced by copied units are brought along. A unit whose launcher record is invalid or whose pin is missing is left for Cargo to build through the wrapper.

On macOS, Kache runs the script with ZERO_AR_DATE=1 unless the variable is already set. Apple ar and libtool use it to avoid archive timestamp churn. Other differences, such as member names and object contents, can still change an archive.

GNU ar on Linux may need deterministic mode enabled explicitly. If an archive's member timestamps change between identical builds, see Native archives change on every clean build.

What a run is keyed on

Kache reuses Cargo's own rule for when a build script must rerun. A recorded run is restored only when all of these match:

  • the script binary's content hash;
  • the values of these variables: CARGO_CFG_*, CARGO_FEATURE_*, CARGO_PKG_*, DEP_*, CARGO_ENCODED_RUSTFLAGS, CARGO_MANIFEST_DIR, CARGO_MANIFEST_LINKS, CARGO_MANIFEST_PATH, TARGET, HOST, PROFILE, OPT_LEVEL, DEBUG, RUSTC, RUSTC_LINKER, RUSTDOC, PKG_CONFIG_PATH, PKG_CONFIG_LIBDIR, PKG_CONFIG_SYSROOT_DIR and PKG_CONFIG_ALLOW_CROSS, with OUT_DIR, the package directory, the target directory and the Cargo home replaced by placeholders. A DEP_* value that names an existing absolute path is also keyed by what is there. NUM_JOBS and the jobserver variables are left out;
  • on macOS, the ZERO_AR_DATE value the script runs with;
  • the values of every variable the script declared with cargo:rerun-if-env-changed;
  • the content and modification time of every path the script declared with cargo:rerun-if-changed, including directory entries and symlink referents (a directory is digested recursively; the digest of a directory whose entries' sizes and timestamps are unchanged is reused from a memo, so a vendored C library costs one stat walk per run). A script that declares nothing depends on its whole package directory, as in Cargo. An absolute declaration outside the mapped package, target and Cargo home roots also binds the run to its package directory. Such runs can be reused across target directories in one checkout. Use a package-relative declaration to share them across checkouts;
  • for each machine-local root a recorded file spells and Kache could not rewrite, that root's value. A UTF-8 text file in which every path starts at OUT_DIR, the package directory, the target directory or the Cargo home (web and Git URLs aside) is stored with placeholders and written back with the current paths, so it does not tie the run to them. Any other file that names one of those roots, or KACHE_BASE_DIR, ties the run to that root: objects that name registry sources restore wherever the Cargo home is the same, and a file that names its own OUT_DIR restores only under that OUT_DIR;
  • the operating system and architecture, and the configured key salt.

A script can embed a file's modification time even when its bytes have not changed. A changed declared timestamp reruns that script. If its files and Cargo directives stay the same, Rust compilation can still hit its content cache. Declared inputs with matching contents and times can share a run. A fresh checkout can need new script runs because Git gives files new times. The implicit package input tracks file times and ignores directory times, so creating an excluded target directory does not invalidate a run. Hermetic runs check the same declared input times. Older run entries use separate keys and cannot satisfy these checks.

Two settings let more runs match across checkouts. A keyed variable that uses KACHE_BASE_DIR as the source of a path prefix map (-ffile-prefix-map=<checkout>=., its -fdebug-, -fmacro- and -fprofile- forms, or --remap-path-prefix) keys that flag the same in every checkout; KACHE_BASE_DIR anywhere else in a value gets no special treatment. With RUSTC, RUSTC_LINKER or RUSTDOC listed in cache.path_only_env_vars, a tool under KACHE_BASE_DIR is keyed by its path below that directory and by its contents, and a run during which the tool changes is not recorded. See Cache-key controls.

A hit empties OUT_DIR and restores the recorded files, directories, executable bits and symlinks. It also writes back what the script named under the target directory, outside OUT_DIR, as a link search path or linker input (cargo:rustc-link-search=<target>/debug/gn_out/obj, a -L in rustc-link-arg or rustc-flags, or an absolute file in rustc-link-arg), so the replayed line finds its library. Directories Cargo fills itself (deps, incremental, build, .fingerprint, examples) and paths outside the target directory are not recorded. A link path inside another Cargo build unit refuses the run unless an absolute DEP_* variable handed that path, or an ancestor inside the same unit, to the script. This also applies when restoring older entries. For example, DEP_Z_ROOT can supply libz-sys's output; a scratch directory owned by a different build unit cannot be reconstructed by replaying this script. Naming the Cargo build directory itself also refuses the run, because that directory has no single owner. Then Kache replays the recorded stdout and stderr with the current paths substituted.

On Unix, links in OUT_DIR can point into the package directory, Cargo's registry/src or git/checkouts directories, or back into OUT_DIR by an absolute path. Kache records each resolved target as a root and a checked relative suffix, and rebuilds the link using the current run's roots. This supports cxx-build's package and registry-header links across checkouts. Cross-checkout reuse also requires the same build-script binary. A binary that embeds OUT_DIR can differ across target directories. Targets must exist and remain in an allowed root; links into the target directory, unknown roots, and dangling external targets refuse the run. The most specific root decides, so a target directory inside the package is still excluded. Relative links confined to OUT_DIR keep their original text, including ordinary dangling links. These rules apply to the regular build-script cache. Hermetic runs still refuse links that leave OUT_DIR and fall back to the regular path. Entries stay in the local store: kache sync never uploads them.

On main, a configured cache.readonly_store is also checked after a local run miss. A restored run does not write to that fallback; a miss in both stores runs the script and records it locally. This is separate from remote artifact sharing. See Read-only store.

Main prepares eligible cached output files concurrently before publication. That changes restore preparation, not the script's declared-input requirements or which outputs may be replayed.

Runs appear in build reports (kache stats --full) as build_script_run, with the package name. A restored run is a local_hit and a recorded one a miss. When Kache bypasses the cache or does not record a successful run, the event is a passthrough whose reason says build-script cache bypassed or build-script run not recorded, with the cause.

What is not cached

  • A script that prints an empty cargo:rerun-if-changed= (always rerun) or declares an input under OUT_DIR.
  • A run that started with a non-empty OUT_DIR, or during which a declared input changed. When the script itself wrote into its inputs, as a generator does when it creates its data directory on the first run, kache runs it once more on the tree it left and records that run instead; Cargo sees only the second run's output. A script whose second run writes into its inputs again is not run twice after that.
  • An OUT_DIR and named target files that together pass 1 GiB or 50,000 non-empty files and symlinks, a named target path that is itself a symlink, or an OUT_DIR link whose resolved target is outside the roots listed above. A relative link confined to OUT_DIR, or a link in a named target directory, must climb no higher than that directory with leading .., then only descend. A link such as libfoo.so -> libfoo.so.1 is recorded and restored as a link, on Unix.
  • A run whose stdout is not UTF-8, or whose OUT_DIR holds a name that is not UTF-8 or an entry other than a file, directory or symlink.
  • A script whose declared inputs add up to more than 100,000 files and directories.
  • Windows, where no launcher is installed.

Declared inputs in the Rust key

A proc macro can read files and variables rustc never reports, and Cargo recompiles every unit of a package after an input its build script declared changes. For a workspace or path package, Kache folds the inputs the package's own build script declared into the key of each of its Rust units: libraries, binaries, tests, examples, benches and proc-macro crates. A macro that reaches a unit through a re-export, as Tauri's generate_context! reaches an app through tauri, is covered as well, because the inputs belong to the package rather than to the macro.

Kache reads the stdout Cargo recorded for the run, by Cargo's rules, including the move to the current OUT_DIR when the target directory moved, and keys:

  • each cargo:rerun-if-changed path, resolved against the package directory as Cargo resolves it. A file counts by its content, a directory by the names and contents of everything in it, a symlink by its target and what it points to, and a missing path as missing. A path through a file counts as missing too, as in Cargo: in a git worktree .git is a file, so a declared .git/HEAD is missing there. A path in the package or in OUT_DIR is named by its place there, so another checkout or target directory gets the same key. A path under the Cargo home's registry or git directory counts by name only, since Cargo treats those as immutable;
  • each cargo:rerun-if-env-changed variable, by its exact value or as unset. A variable Cargo itself sets on the rustc command, such as OUT_DIR, CARGO_MANIFEST_DIR or CARGO_PKG_VERSION, counts by name only. A variable whose name ends in _OUT_DIR counts by name only when it holds another OUT_DIR of the package, as Cargo sets it for a package with several build scripts; otherwise it counts by value like any other.

A script that declares nothing, or prints an empty cargo:rerun-if-changed=, depends on its package, as in Cargo. Kache then keys the package's files other than Rust sources, which the compile reports itself when it reads them, and leaves out sub-packages. Every directory walk leaves out VCS metadata (.git, .hg, .jj, .svn), directories holding Cargo's CACHEDIR.TAG, the target directory, and Kache's own cache and runtime directories. It also leaves out what Cargo writes in its home: the downloads in registry and git, its .global-cache record and lock files, and what cargo install adds. A CI job may keep Kache's cache and Cargo's home inside the project, as GitLab requires for cached paths. The rest of Cargo's home counts, so with the home in the project's .cargo, .cargo/config.toml is still one of the root package's files.

What rustc could not read either counts as such. rustc runs as the same user as Kache, so a file the user may not read, or a directory the user may neither list nor search (a Docker volume owned by another user, say), counts as unreadable, and changes inside it do not move the key. A symlink to a directory the walk is already inside, such as self -> ., counts as a cycle instead of being followed again.

The inputs are read again after a compile. When anything Kache keyed changed while rustc ran, the result is not stored, and the event is skipped with the reason build-script-inputs-changed.

A unit compiles without Kache and without a fallback wrapper when its declared paths hold more than 20,000 entries or cannot be read for another reason, such as a directory the user may search but not list. Its passthrough reason starts with uncacheable|build-script inputs:. A package whose script declares nothing, and that holds more than 20,000 entries or cannot be walked, is keyed without its files; with KACHE_LOG=warn, Kache says so once per session. Printing cargo:rerun-if-changed for build.rs and for each file the script or the package's macros read keys such a package precisely. Declare directories when new files in them should trigger a rerun.

A unit whose script declares a path or a variable never goes to a configured fallback wrapper, which would key it without them. When Kache does not cache such a unit, rustc runs directly.

Each unit pays for this on every compile Cargo asks for. Kache reads Cargo's record of the run and checks each declared file. A declared directory, or the package of a script that declares nothing, costs one stat per entry, plus a hash of each file the first time Kache sees the tree and after it changes. After a compile that missed, the check runs again. On an Apple M5 Pro, a package of 10,000 small files added about 45 ms to each unit's key on a warm cache and about 0.5 s on the first build in a checkout. A script that prints cargo:rerun-if-changed for build.rs and for the files it or its macros read avoids the package walk.

This does not cover:

  • inputs another package's build script declares, such as a proc-macro crate's own script, or a registry crate's variable that its re-exported macro reads. Declare those with extra inputs or cache.key_env_vars;
  • reads no build script declares;
  • registry, Git and vendored packages. Their sources are versioned, and the tool variables their scripts declare (CC, CFLAGS, PKG_CONFIG_PATH) would split their keys between machines;
  • rustdoc output.

Hermetic runs

Set KACHE_BUILD_SCRIPT_HERMETIC=1, or [cache] build_script_hermetic = true (Unix, not as root), to share runs through one OUT_DIR path instead. The script runs with OUT_DIR in a directory named by its key, under out-dirs/v2/ in the cache directory, so every target directory that shares the key sees the same path. Values the script derives from OUT_DIR (its length, a hash, an escaped spelling) are then the same everywhere too. The regular cache cannot check for such values.

  • The key covers everything the regular key does, with every value as the script sees it rather than with roots replaced. OUT_DIR counts only by where it sits below the target directory, and cargo:rerun-if-env-changed=OUT_DIR by its name.
  • The key's directory repeats the layout below the target directory, with OUT_DIR at the same place. The script runs from a copy of its binary in that directory, with CARGO_TARGET_DIR set to it, without PWD and OLDPWD, and with entries under the real target directory removed from LD_LIBRARY_PATH, DYLD_LIBRARY_PATH and DYLD_FALLBACK_LIBRARY_PATH.
  • A run is kept only when it succeeds, declares the inputs its key was computed from, leaves them unchanged, writes nothing outside OUT_DIR, and creates only relative symlinks that climb no higher than OUT_DIR with leading .. and then only descend (lib/foo -> sub/../libfoo.so.1 does not). Otherwise the script runs as usual.
  • A kept run is made read-only, and Cargo's OUT_DIR becomes a symlink to it. A hit creates that link: nothing is copied, and every worktree shares one copy. rustc-link-search lines are replayed with Cargo's own OUT_DIR, so cargo run and cargo test still find libraries there.
  • The first run of a script records what it declares, so hermetic runs start with the second.

Registry and Git dependencies live under the Cargo home, so their keys match across worktrees. A workspace member's manifest directory is part of its key, so it shares only between target directories of the same checkout.

Every link is recorded beside the shared directory, including the links seeding makes in a new checkout's target directory. GC (kache gc and the daemon's sweeps) keeps a directory while any recorded OUT_DIR still links to it, and removes it once none has for seven days. Emptying the cache with kache clean --cache removes every directory no target directory links to. A directory a build is using is left for the next sweep.

Shared directories are read-only: make them writable (chmod -R u+w) before deleting the cache directory by hand. Target directories keep their links, so run cargo clean in them afterwards. Run it before building without Kache too: Cargo considers the linked runs fresh and would run a script directly against a read-only OUT_DIR. A target directory seeded from one built with hermetic runs has these links too, even if it never builds with KACHE_BUILD_SCRIPT_HERMETIC=1.

A run that cannot be kept is remembered per key for seven days, or until the cache is emptied, so its script runs once, as usual, on later builds instead of twice.

Limits

Without hermetic runs, a result restores in another target directory when each recorded file that names a machine-local root is text Kache rewrote, or that root has the same value there, such as a Cargo home both checkouts share. A value the script derives from a root without spelling it, such as its length or a hash, is restored unchanged and can be wrong there.

The key covers what Cargo compares. It does not cover state a script probes without declaring it: system libraries, pkg-config results, or tools found on PATH. Cargo has the same blind spot with a warm target directory; with Kache it also survives cargo clean. Side effects outside OUT_DIR and the target files a script names as link inputs, such as files it writes into its package directory, are not replayed either.

Set KACHE_BUILD_SCRIPT_CACHE=0 to run every build script. Existing launchers then run the preserved binary directly.

Available for:
Apple macOS logomacOSMicrosoft Windows logoWindowsLinux logoLinux
Download Kunobi