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_DIRandPKG_CONFIG_ALLOW_CROSS, withOUT_DIR, the package directory, the target directory and the Cargo home replaced by placeholders. ADEP_*value that names an existing absolute path is also keyed by what is there.NUM_JOBSand the jobserver variables are left out; - on macOS, the
ZERO_AR_DATEvalue 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, orKACHE_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 ownOUT_DIRrestores only under thatOUT_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 underOUT_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_DIRand 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 anOUT_DIRlink whose resolved target is outside the roots listed above. A relative link confined toOUT_DIR, or a link in a named target directory, must climb no higher than that directory with leading.., then only descend. A link such aslibfoo.so -> libfoo.so.1is recorded and restored as a link, on Unix. - A run whose stdout is not UTF-8, or whose
OUT_DIRholds 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-changedpath, 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.gitis a file, so a declared.git/HEADis missing there. A path in the package or inOUT_DIRis named by its place there, so another checkout or target directory gets the same key. A path under the Cargo home'sregistryorgitdirectory counts by name only, since Cargo treats those as immutable; - each
cargo:rerun-if-env-changedvariable, by its exact value or as unset. A variable Cargo itself sets on the rustc command, such asOUT_DIR,CARGO_MANIFEST_DIRorCARGO_PKG_VERSION, counts by name only. A variable whose name ends in_OUT_DIRcounts by name only when it holds anotherOUT_DIRof 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; rustdocoutput.
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_DIRcounts only by where it sits below the target directory, andcargo:rerun-if-env-changed=OUT_DIRby its name. - The key's directory repeats the layout below the target directory, with
OUT_DIRat the same place. The script runs from a copy of its binary in that directory, withCARGO_TARGET_DIRset to it, withoutPWDandOLDPWD, and with entries under the real target directory removed fromLD_LIBRARY_PATH,DYLD_LIBRARY_PATHandDYLD_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 thanOUT_DIRwith leading..and then only descend (lib/foo -> sub/../libfoo.so.1does not). Otherwise the script runs as usual. - A kept run is made read-only, and Cargo's
OUT_DIRbecomes a symlink to it. A hit creates that link: nothing is copied, and every worktree shares one copy.rustc-link-searchlines are replayed with Cargo's ownOUT_DIR, socargo runandcargo teststill 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.