Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3b8a43a
Add opt-in mold and Cranelift local build acceleration
Jul 28, 2026
2a53e40
Document the local build acceleration contract
Jul 28, 2026
a03934e
Honour the overridden dev-fast prefix and harden diagnostics
leynos Jul 29, 2026
9966e89
Clarify which prefix the dev-fast recipes put on PATH
leynos Jul 29, 2026
68ab7ca
Add an install-dev-fast flow diagram
leynos Jul 29, 2026
c336b71
Test the dev-fast tooling and document its helpers
leynos Jul 29, 2026
7edbec8
Replace string plumbing in the dev-fast tests with typed fixtures
leynos Jul 29, 2026
f72aee3
Collapse blank lines left by the rebase merge
leynos Jul 30, 2026
2cea7d3
Compose the sandbox fake over main's stub primitive
leynos Jul 30, 2026
5f6d440
Route dev-test through nextest to match the gate
leynos Jul 31, 2026
9b10ed7
Default the dev-fast pin paths from the script location
leynos Jul 31, 2026
34fee0e
Address the dev-fast review round
leynos Jul 31, 2026
581628c
Share the repository toolchain and restate the Polonius flag
leynos Aug 1, 2026
79d390f
Split the benchmark test into fixture, run, and checks
leynos Aug 1, 2026
f5a689f
Warn that test_support's own tests are not gated
leynos Aug 1, 2026
80ac4a9
Record why the sandbox qualifies for the subprocess exemption
leynos Aug 1, 2026
e4c86a2
Model the checksum and clean-build invariants, and fix a hole
leynos Aug 2, 2026
a7fc197
Reject malformed pins, fail on drift, and split by feature
leynos Aug 2, 2026
b417984
Harden the trap and quoting, and correct the benchmark evidence
leynos Aug 2, 2026
85e0fef
Pin the drift regression to its cause
leynos Aug 2, 2026
31ce521
Bound the download, share the installer fixture, revive the seeds
leynos Aug 2, 2026
1b8d9eb
Make the timestamps portable and the fallbacks tested
leynos Aug 2, 2026
b04f5da
Make a benchmark run indivisible
leynos Aug 2, 2026
39e3572
Say when the timestamp could not be put back
leynos Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help all clean test test-nextest doctest test-workflow-contracts test-typos-config build release lint lint-clippy lint-whitaker fmt check-fmt typecheck markdownlint spelling spelling-config spelling-helper-test nixie install-kani kani-check kani-full kani-ir install-verus verus formal-pr
.PHONY: help all clean test test-nextest doctest test-workflow-contracts test-typos-config build release lint lint-clippy lint-whitaker fmt check-fmt typecheck markdownlint spelling spelling-config spelling-helper-test nixie install-kani kani-check kani-full kani-ir install-verus verus formal-pr install-dev-fast dev-fast-check dev-build dev-test bench-build

APP ?= netsuke
CARGO ?= $(shell command -v cargo 2>/dev/null || printf '%s' "$$HOME/.cargo/bin/cargo")
Expand All @@ -18,6 +18,23 @@ KANI_FLAGS ?=
KANI_INSTALL_FLAGS ?=
KANI_CHECK_FLAGS ?=
KANI_VERSION_FILE ?= tools/kani/VERSION
# Opt-in local build acceleration. The Cargo fragment is deliberately separate
# from `.cargo/config.toml`: that file is auto-discovered and carries the
# repository-wide Polonius flag, whereas Cranelift and mold must stay opt-in so
# release, packaging, coverage, and formal-verification paths keep the
# supported LLVM backend and platform linker. The toolchain is not pinned
# separately — dev-fast uses the repository's own nightly.
MOLD_VERSION_FILE ?= tools/mold/VERSION
MOLD_SHA256SUMS_FILE ?= tools/mold/SHA256SUMS
RUST_TOOLCHAIN_FILE ?= rust-toolchain.toml
DEV_FAST_CONFIG ?= tools/dev-fast/config.toml
DEV_FAST_PREFIX ?= $(HOME)/.local
# Exported rather than interpolated into the recipes. Make hands an exported
# variable to the child process directly, so a path containing a quote cannot
# break the command line the shell parses; a `VAR='$(VAR)'` prefix could.
export MOLD_VERSION_FILE MOLD_SHA256SUMS_FILE RUST_TOOLCHAIN_FILE
export DEV_FAST_CONFIG DEV_FAST_PREFIX
DEV_FAST_TOOLCHAIN = $$(awk -F'"' '/^[[:space:]]*channel[[:space:]]*=/ { print $$2; exit }' '$(RUST_TOOLCHAIN_FILE)')
MDLINT ?= $(shell command -v markdownlint-cli2 2>/dev/null || printf '%s' "$$HOME/.bun/bin/markdownlint-cli2")
NIXIE ?= nixie
# Single source of truth for the typos version; the markdownlint target and CI
Expand Down Expand Up @@ -148,6 +165,28 @@ verus: ## Run the Verus proof entry point
formal-pr: ## Run pull-request formal-verification checks
$(MAKE) kani-check

install-dev-fast: ## Install the pinned mold linker and Cranelift backend
@scripts/install-dev-fast.sh

dev-fast-check: ## Check the mold and Cranelift local build prerequisites
@scripts/dev-fast-check.sh

# Every dev-fast target needs the install prefix ahead of a distribution mold:
# the check probes PATH for it, and `-fuse-ld=mold` resolves by PATH order.
# Target-specific exports do not reach prerequisites, so `dev-fast-check` is
# listed in its own right as well as being a prerequisite of the others.
DEV_FAST_TARGETS = install-dev-fast dev-fast-check dev-build dev-test bench-build
$(DEV_FAST_TARGETS): export PATH := $(DEV_FAST_PREFIX)/bin:$(PATH)

dev-build: dev-fast-check ## Build the debug binary with Cranelift and mold
RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) $(CARGO) --config "$$DEV_FAST_CONFIG" build $(BUILD_JOBS) --bin $(APP)

dev-test: dev-fast-check ## Run the nextest pass with Cranelift and mold
RUSTUP_TOOLCHAIN=$(DEV_FAST_TOOLCHAIN) $(CARGO) --config "$$DEV_FAST_CONFIG" nextest run --all-targets --all-features $(NEXTEST_BUILD_JOBS)

bench-build: dev-fast-check ## Time clean and incremental debug builds for both paths
@CARGO="$(CARGO)" scripts/bench-build.sh

help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS=":"; printf "Available targets:\n"} {printf " %-20s %s\n", $$1, $$2}'
3 changes: 2 additions & 1 deletion docs/contents.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ operator, user, and contributor references are easier to find.
## Contributor guidance

- [developers-guide.md](developers-guide.md): Engineering workflow, quality
gates, testing strategy, and stdlib resolver-boundary conventions.
gates, local build acceleration, testing strategy, and stdlib
resolver-boundary conventions.
- [polonius.md](polonius.md): Polonius migration audit, borrow-centric API
evolution log, and principled refusals.
- [documentation-style-guide.md](documentation-style-guide.md): Documentation
Expand Down
414 changes: 384 additions & 30 deletions docs/developers-guide.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion docs/repository-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ output and some leaf files so the long-lived structure remains visible.
│ ├── fixtures/
│ └── snapshots/
└── tools/
└── kani/
├── dev-fast/
├── kani/
└── mold/
```

## Path responsibilities
Expand Down Expand Up @@ -97,8 +99,13 @@ output and some leaf files so the long-lived structure remains visible.
- `tests/features/`: Cross-platform behavioural feature files.
- `tests/features_unix/`: Unix-specific behavioural feature files.
- `tests/snapshots/`: Checked-in integration-test snapshots.
- `tools/dev-fast/`: Non-auto-loaded Cargo configuration fragment for the
opt-in Cranelift and `mold` build path. Cargo never discovers this file on
its own; only the `make dev-*` targets pass it through `cargo --config`.
- `tools/kani/`: Kani formal-verification harness configuration and related
local tooling.
- `tools/mold/`: Pinned `mold` linker release version and the SHA-256 checksums
used to verify the downloaded release artefacts.

## Placement conventions

Expand Down
168 changes: 168 additions & 0 deletions scripts/bench-build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Benchmark the default (LLVM + platform linker) debug build against the opt-in
# mold + Cranelift path.
#
# Each variant is measured twice: a clean build from an empty target directory,
# and an incremental rebuild after touching the binary's entry point. Variants
# use separate target directories so neither warms nor invalidates the other's
# cache, and neither disturbs the working `target/` tree. Results are printed as
# a Markdown table so the developers' guide can be regenerated verbatim.

set -euo pipefail

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=scripts/dev-fast-common.sh
. "$script_dir/dev-fast-common.sh"

: "${CARGO:=cargo}"
: "${DEV_FAST_CONFIG:?DEV_FAST_CONFIG must be set}"

# The timer below reads EPOCHREALTIME, which Bash gained in 5.0. Fail here with
# a named prerequisite rather than silently reporting every duration as zero.
[ "${BASH_VERSINFO[0]:-0}" -ge 5 ] ||
fail "bash 5.0 or newer is required to benchmark; found ${BASH_VERSION:-unknown}"

BENCH_ROOT=${BENCH_ROOT:-target/bench}
BENCH_BIN=${BENCH_BIN:-netsuke}
BENCH_TOUCH_FILE=${BENCH_TOUCH_FILE:-src/main.rs}
BENCH_LOCK_DIR=${BENCH_LOCK_DIR:-$BENCH_ROOT.lock}

# Populated as "<label>|<clean seconds>|<incremental seconds>" rows.
results=()

# The benchmark touches BENCH_TOUCH_FILE to make the second pass incremental,
# and that file defaults to a tracked source. Leaving it newer than the ordinary
# `target/` outputs would silently force the developer's next real build to redo
# work, long after the benchmark finished, so the timestamp is restored on exit —
# including when a measurement fails or the run is interrupted.
#
# This holds a scratch file whose own timestamp is the one to put back, rather
# than an epoch number: `touch -r` is POSIX, whereas reading the stamp with
# `stat -c` and replaying it with `touch -d @epoch` is GNU-only and fails on
# macOS. The benchmark is reachable there, because the capability check tolerates
# a non-Linux host rather than aborting.
BENCH_TOUCH_STAMP=

restore_touch_file() {
[ -n "$BENCH_TOUCH_STAMP" ] || return 0
# Swallowing this would be the worst of both worlds: the developer keeps the
# consequence — a source file left newer than the build outputs, so the next
# real build silently redoes work — and loses the only notice that it
# happened. The trap must not abort the run, so warn rather than fail, and say
# enough that the state can be checked and put right by hand.
touch -r "$BENCH_TOUCH_STAMP" "$BENCH_TOUCH_FILE" ||
note "failed to restore the timestamp of $BENCH_TOUCH_FILE; it is left newer than before the benchmark, so the next build will redo work. Check it with: ls -l $BENCH_TOUCH_FILE"
rm -f "$BENCH_TOUCH_STAMP"
BENCH_TOUCH_STAMP=
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Two benchmark runs in one checkout are not independent: they share the variant
# target directories, so one run's `rm -rf` for its clean pass deletes the other
# run's warm cache mid-measurement, and they share the touch file, so the second
# run captures a stamp the first has already moved and restores that instead of
# the original. The result is neither a crash nor a comparable figure — it is a
# plausible-looking table and a permanently newer source file.
#
# So take the run exclusively rather than documenting the hazard. `mkdir` is the
# portable atomic test-and-set: it succeeds for exactly one caller and needs no
# `flock`, which is util-linux and absent on macOS, where this script is
# reachable because the capability check tolerates a non-Linux host.
BENCH_LOCK_HELD=

acquire_bench_lock() {
mkdir -p -- "$(dirname -- "$BENCH_LOCK_DIR")"
mkdir -- "$BENCH_LOCK_DIR" 2>/dev/null || fail \
"another benchmark run holds $BENCH_LOCK_DIR; wait for it to finish, or remove that directory if it was left behind by a killed run"
BENCH_LOCK_HELD=1
}

release_bench_lock() {
[ -n "$BENCH_LOCK_HELD" ] || return 0
rmdir -- "$BENCH_LOCK_DIR" 2>/dev/null || true
BENCH_LOCK_HELD=
}

# One handler for both, so an interrupted run releases the lock as well as
# restoring the timestamp. Each half is idempotent, so EXIT firing after INT or
# TERM is harmless.
cleanup() {
restore_touch_file
release_bench_lock
}

trap cleanup EXIT INT TERM

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Wall-clock seconds for a command, to one decimal place. EPOCHREALTIME keeps
# the measurement sub-second without shelling out to an external timer.
time_command() {
local start=${EPOCHREALTIME/,/.} end
# Suppress stdout only. This function's stdout is captured by the caller, so
# build chatter would corrupt the measurement, but stderr must reach the
# terminal: without it a failing build reports only "benchmark command
# failed" and hides the compiler or linker diagnostic that explains why.
"$@" >/dev/null || fail "benchmark command failed: $*"
end=${EPOCHREALTIME/,/.}
LC_ALL=C awk -v start="$start" -v end="$end" 'BEGIN { printf "%.1f", end - start }'
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Usage: measure_variant <slug> <label> <command...>
# The slug names the variant's private target directory; the label is the table
# caption for that row.
measure_variant() {
local slug=$1 label=$2
local clean incremental
shift 2
export CARGO_TARGET_DIR="$BENCH_ROOT/$slug"

note "measuring $label (clean)"
rm -rf "$CARGO_TARGET_DIR"
clean=$(time_command "$@")

note "measuring $label (incremental)"
if [ -z "$BENCH_TOUCH_STAMP" ]; then
BENCH_TOUCH_STAMP=$(mktemp)
touch -r "$BENCH_TOUCH_FILE" "$BENCH_TOUCH_STAMP"
fi
touch "$BENCH_TOUCH_FILE"
Comment on lines +122 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the touch-file timestamp capture against failure.

restore_touch_file warns when the restoring touch -r fails, but the initial capture at line 124 has no failure check. If touch -r "$BENCH_TOUCH_FILE" "$BENCH_TOUCH_STAMP" fails, BENCH_TOUCH_STAMP still exists (created by mktemp), so it keeps its own creation-time mtime instead of the real original mtime of $BENCH_TOUCH_FILE.

restore_touch_file only checks that BENCH_TOUCH_STAMP is non-empty, not that the capture succeeded. On exit, it applies that wrong-but-plausible stamp to $BENCH_TOUCH_FILE with no warning, because the restoring touch -r call itself succeeds. This defeats the exact protection the surrounding comments describe: the developer's tracked source is left with a corrupted timestamp, and nothing on screen explains it.

Set BENCH_TOUCH_STAMP only after a successful capture, and abort immediately otherwise.

🛡️ Proposed fix to guard the capture
   note "measuring $label (incremental)"
   if [ -z "$BENCH_TOUCH_STAMP" ]; then
-    BENCH_TOUCH_STAMP=$(mktemp)
-    touch -r "$BENCH_TOUCH_FILE" "$BENCH_TOUCH_STAMP"
+    local stamp
+    stamp=$(mktemp)
+    if touch -r "$BENCH_TOUCH_FILE" "$stamp"; then
+      BENCH_TOUCH_STAMP=$stamp
+    else
+      rm -f "$stamp"
+      fail "failed to capture the timestamp of $BENCH_TOUCH_FILE before benchmarking; aborting rather than risk restoring the wrong value"
+    fi
   fi
   touch "$BENCH_TOUCH_FILE"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if [ -z "$BENCH_TOUCH_STAMP" ]; then
BENCH_TOUCH_STAMP=$(mktemp)
touch -r "$BENCH_TOUCH_FILE" "$BENCH_TOUCH_STAMP"
fi
touch "$BENCH_TOUCH_FILE"
note "measuring $label (incremental)"
if [ -z "$BENCH_TOUCH_STAMP" ]; then
local stamp
stamp=$(mktemp)
if touch -r "$BENCH_TOUCH_FILE" "$stamp"; then
BENCH_TOUCH_STAMP=$stamp
else
rm -f "$stamp"
fail "failed to capture the timestamp of $BENCH_TOUCH_FILE before benchmarking; aborting rather than risk restoring the wrong value"
fi
fi
touch "$BENCH_TOUCH_FILE"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bench-build.sh` around lines 122 - 126, Update the BENCH_TOUCH_STAMP
capture block to create the temporary file and run touch -r as a single guarded
operation, assigning BENCH_TOUCH_STAMP only when the timestamp copy succeeds. If
either creation or capture fails, abort immediately instead of leaving a
non-empty invalid stamp for restore_touch_file.

incremental=$(time_command "$@")

unset CARGO_TARGET_DIR
results+=("$label|$clean|$incremental")
}

# Render the accumulated rows as a Markdown table, ready to paste into the
# developers' guide.
report() {
local row label clean incremental
printf '\n| Variant | Clean build (s) | Incremental build (s) |\n'
printf '| --- | --- | --- |\n'
for row in "${results[@]}"; do
IFS='|' read -r label clean incremental <<<"$row"
printf '| %s | %s | %s |\n' "$label" "$clean" "$incremental"
done
}

# Measure the default path first so its numbers are not attributed to a warm
# page cache created by the accelerated run.
main() {
local toolchain
toolchain=$(cranelift_toolchain)

# Before the first `rm -rf` or `touch`, so a rejected run leaves the holder's
# state untouched.
acquire_bench_lock

measure_variant default 'Default (LLVM, platform linker)' \
"$CARGO" build --bin "$BENCH_BIN"

# The label is backticked because the developers' guide embeds this table
# verbatim, and the repository spelling gate reads a bare "mold" as "mould".
# shellcheck disable=SC2016 # the backticks are Markdown, not a subshell.
measure_variant dev-fast 'dev-fast (Cranelift, `mold`)' \
env RUSTUP_TOOLCHAIN="$toolchain" \
"$CARGO" --config "$DEV_FAST_CONFIG" build --bin "$BENCH_BIN"

report
}

main "$@"
92 changes: 92 additions & 0 deletions scripts/dev-fast-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Fast capability check for the opt-in mold + Cranelift local build path.
#
# Runs before `make dev-build` and `make dev-test` so a missing tool produces an
# actionable installation hint rather than an opaque codegen-backend or linker
# failure deep inside a Cargo invocation. Exits non-zero when a required
# component is absent, unusable, or does not match its pin.

set -euo pipefail

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=scripts/dev-fast-common.sh
. "$script_dir/dev-fast-common.sh"

# Report on the linker half of the prerequisites. Returns non-zero when mold is
# required but missing, unusable, or a different version from the pin. Only a
# non-Linux host is tolerated, with a note explaining what is used instead.
check_mold() {
local pinned=$1 installed resolved
if ! is_linux; then
note "mold is Linux-only; falling back to the default $(uname -s) linker"
return 0
fi
if ! resolved=$(command -v mold 2>/dev/null); then
note "mold not found on PATH (pinned $pinned)"
note 'install it with: make install-dev-fast'
return 1
fi
# A mold that cannot report its version is broken — a truncated download or
# an unresolved shared library — so treat it as a failure rather than letting
# the empty string surface as a confusing version-drift warning.
if ! installed=$(installed_mold_version) || [ -z "$installed" ]; then
note "mold at $resolved is on PATH but cannot report its version"
note 'reinstall it with: make install-dev-fast'
return 1
fi
# Report the resolved path, not just the version: `-fuse-ld=mold` selects by
# PATH order, so naming the winner makes an unexpected pick obvious.
#
# A drift from the pin fails rather than warns. An advisory pin is not a pin:
# tolerating it means the linker actually used, and so the benchmark figures
# and any linker-specific behaviour, silently stop matching what the
# repository claims. `make install-dev-fast` puts the pinned release ahead of
# a distribution one on PATH, so the remedy is a single command.
if [ "$installed" != "$pinned" ]; then
note "mold $installed at $resolved does not match the pin $pinned"
note 'run make install-dev-fast to match'
return 1
fi
note "mold $installed at $resolved"
}

# Report on the toolchain half of the prerequisites: rustup itself, the pinned
# nightly, and the Cranelift backend component. Any absence is fatal, because
# there is no meaningful fallback for a missing codegen backend.
check_cranelift() {
local toolchain=$1
if ! command -v rustup >/dev/null 2>&1; then
note 'rustup not found on PATH; it is required to select the pinned nightly'
note 'install it from https://rustup.rs'
return 1
fi
if ! rustup toolchain list | grep -q "^$toolchain"; then
note "toolchain $toolchain is not installed"
note 'install it with: make install-dev-fast'
return 1
fi
if ! has_cranelift_component "$toolchain"; then
note "$CRANELIFT_COMPONENT is not installed for $toolchain"
note 'install it with: make install-dev-fast'
return 1
fi
note "$CRANELIFT_COMPONENT available on $toolchain"
}

# Run both checks unconditionally so a developer sees every missing piece in one
# pass rather than fixing them one failed run at a time.
main() {
local status=0 mold_pin toolchain_pin
# Resolve the pins into variables first. `fail` exits, but inside a command
# substitution that exit kills only the subshell, so passing `$(mold_version)`
# straight into a check would continue with an empty pin and report a
# nonsensical drift. An assignment propagates the status, so this stops.
mold_pin=$(mold_version) || return 1
toolchain_pin=$(cranelift_toolchain) || return 1
check_mold "$mold_pin" || status=1
check_cranelift "$toolchain_pin" || status=1
[ "$status" -eq 0 ] || note 'capability check failed; see the messages above'
return "$status"
}

main "$@"
Loading
Loading