Skip to content
shippy

Build scripts

shippy caches the runs of build scripts (build.rs) like compilations, and the box runs them in its sandbox so one person's run can be everyone's. Read this when a cold build is slow even with every crate restored, or when you need to decide which of your own packages' scripts may be shared.

Why it matters

A cold build with every compilation restored can still be minutes of build scripts: C and C++ compiled by cc, cmake and make (rocksdb, jemalloc, aws-lc), and code generated into OUT_DIR.

On a small machine those scripts are most of a cold build. A client of a warm box that compiled rocksdb and jemalloc itself took 332 s. The same build with their runs restored took 136 s.

The runs of downloaded packages' scripts are cached with no extra switch. Without a remote they are kept in this machine's store, for a second checkout or a cleaned target directory. With one they are shared:

shippy setup --remote http://box:7447 --token-file token.txt
shippy build   # dependency build scripts are restored from the box when it has them

What a run's key covers

A run is one execution of a build script. Like a compilation, it has a recipe and a key. The key covers what cargo's own rules say a run depends on:

WhatExamples
The script's executableby content
The package directoryevery file by content, which are executable, its links
What dependencies' scripts exportfiles in the directories DEP_* variables name
What cargo and cc give itTARGET, PROFILE, OPT_LEVEL, CARGO_FEATURE_*, CARGO_CFG_*, CC, CFLAGS, SOURCE_DATE_EPOCH
The Rust compilerrustc -vV
The C toolchainfirst line of cc --version and c++ --version, and the C library's version
What the run itself namedvariables from rerun-if-env-changed, files from rerun-if-changed outside what the rows above hold

The first six rows are known before the script runs. They form the pre-key, so a package is looked up without running anything. The last row is learned from a run and picks among runs of one pre-key.

PATH and HOME are each machine's own. A script that names one depends on its machine, and its run is not shared. NUM_JOBS and the jobserver are in no key.

The result is OUT_DIR packed as one tree, plus what the script printed, which is replayed to cargo.

Which scripts are cached

SHIPPY_SCRIPTS=deps shippy build
SHIPPY_SCRIPTSWhose runs are cached
0Nobody's. Cargo runs every script, as without shippy.
depsPackages from a registry or a git repository. The default, with a remote or without one.
1Every package's.

In shippy.toml, build_scripts = true under [cache] is 1 and false is 0.

Remote execution changes who runs a script nobody has run yet, not whose runs are cached:

The machineThe box has a public runThe box has none
Uses the cache onlyrestoredruns here, uploaded as a claim
Has remote execution onrestoredthe box runs it on request

Shared and local runs

A run is shared (other machines may get it) or local (kept in this machine's store only). The two never have one key.

The script ofIts run
A registry or git dependencyshared: the package directory is the same for everyone
A workspace packagelocal, unless named in share_scripts
A package in local_scriptslocal
A path dependency outside the workspacelocal

A shared run becomes local for this machine when the recipe cannot cover what it reads, for example:

  • the script asks about its git repository (.git/, rev-parse);
  • the package holds a link that leads out of it;
  • the package is behind a link, such as ~/.cargo/registry on another disk;
  • the package and its exports exceed SHIPPY_SCRIPT_MAX_BYTES (1 GiB) or SHIPPY_SCRIPT_MAX_FILES (50,000);
  • the run names PATH or HOME, or a rerun-if-changed path the inputs do not hold.

To see what happened to each script, look at <store>/sessions/<session>.scripts (one line per script: package, shared or local, restored, run-on-the-box or ran-here, and why), or set SHIPPY_DEBUG=1.

How a shared run becomes public

Only the box's own run is ever public. There are two ways to get there.

Loading diagram...
  • A claim. The script ran on a client and the run is uploaded with the files the box lacks. The box runs the recipe in its sandbox and compares. When they agree, the box's run is published, never the claim's bytes. When they differ, or the script fails in the sandbox, nothing is published and the reason is recorded.
  • A request. With remote execution on, a missed shared run goes to POST /v1/exec. The box runs the script, publishes the run, and sends back the packed OUT_DIR, which is restored like a hit. If anything goes wrong, the script runs here.

Either way a run is published under the variables its script named with rerun-if-env-changed and the workspace files it named with rerun-if-changed, and nothing else its sender declared.

The claim path is the safer of the two: the box compares two runs before publishing. A request has no run to compare with.

When a claim and the sandbox "agree"

Two honest runs on two machines differ: compiled code holds the directory it was compiled in, build logs hold times and host names. So the comparison checks that:

  • the directives are the same set, warnings aside;
  • every file that is not compiled code (objects, archives, shared objects) and not build-system bookkeeping (CMakeFiles, config.log, Makefile, *.d) has the same content in both, and neither run has a file the other lacks.

Generated source is what this protects.

What the sandbox holds

No network, the system call filter, the limits. Inside: the script's executable and the package directory (read-only), the declared files of other packages and the target directory, an empty OUT_DIR, the run's own /tmp as HOME, the toolchain whose rustc -vV matches the recipe, the system's C toolchain, and the recipe's variables. Nothing else. A script that needs what its key does not name fails there, and runs on each machine instead.

What a published run may tell cargo

A restored run's directives act on the machine that restores it, so a run is published only when every directive means the same on any machine:

DirectiveShared when
rustc-link-searchthe directory is in OUT_DIR or the package
rustc-link-libthe library is in such a directory, or comes with the C toolchain: c, m, dl, rt, pthread, util, resolv, gcc, gcc_s, atomic, stdc++, c++, c++abi; for an Apple target also System, objc, iconv and the frameworks CoreFoundation, CoreServices, Foundation, IOKit, Security, SystemConfiguration
rustc-env, rustc-cfg, rustc-link-arg*it names no absolute path outside the build's directories, also not as //usr/lib, as a path of the toolchain's directory, or as one that climbs out of the package and OUT_DIR with ..; and a linker argument names no library but such a one
rerun-if-env-changedthe variable is not PATH or HOME
rustc-flags, errornever
warningalways

A script that links a system library (rustc-link-lib=ssl after a pkg-config probe) is the machine's own. Such scripts are usually quick and run on each machine, in every new target directory: the same rule decides whether a machine restores a run it made itself. One difference: a machine's own run may name a file outside the build with rerun-if-changed, as bindgen names the system headers it read. The run's key holds that file's content, so the run is restored while the header is the same and the script runs again once it has changed.

What is never shared

  • A workspace package's script, unless named in share_scripts.
  • The script of a git dependency that is one package of a larger repository, unless a trusted principal ran it. It can read the repository around its package, which no key covers and the sandbox does not hold.
  • A script that asks about its git repository (vergen, built, shadow-rs, git describe). The sandbox has no repository.
  • A script whose run names its machine: a system library, an absolute path, PATH.
  • A script whose claimed run differs from the sandbox's (it stamps the time, or reads a variable it does not name).
  • A script that needs the network or a tool the box lacks.

Settings

On the machine that builds:

SettingDefaultWhat it does
SHIPPY_SCRIPTS, or build_scripts under [cache]depsWhose runs are cached (above).
share_scripts = ["a"] under [cache] (SHIPPY_SCRIPTS_SHARE=a)noneWorkspace packages whose scripts read only their own package and what they declare.
local_scripts = ["c"] under [cache] (SHIPPY_SCRIPTS_LOCAL=c)nonePackages whose scripts always run here.
SHIPPY_SCRIPT_MAX_BYTES, SHIPPY_SCRIPT_MAX_FILES1 GiB, 50,000The most a shared run declares.
SHIPPY_SCRIPT_TIMEOUT1800Seconds a run on the box may take, waiting included, before the script runs here.

On the box (in the [env] table of shippyd.toml):

VariableDefaultWhat it does
SHIPPYD_SCRIPTS10 runs no build scripts: claims stay claims, requests are refused.
SHIPPYD_SCRIPT_TIMEOUT1800Seconds one script may run.
SHIPPYD_SCRIPT_JOBShalf of shippyd's CPUs, 2 to 32NUM_JOBS for the script, and its CPUs when runs have cgroups.
SHIPPYD_SCRIPT_OUTPUT_BYTES8 GiBWhat one script may leave in OUT_DIR.
SHIPPYD_SCRIPT_OUTPUT_FILES200,000How many files and directories.

Go deeper