Artifact e184de2f7396ae88c1266d565092282dc9aff151fab8f971840d867562f7d1be:
- Executable file
scripts/soak.sh
— part of check-in
[9f00c44a8f]
at
2026-07-20 03:36:42
on branch h1-soak
— Add scripts/soak.sh: out-of-band deep DST + fuzz soak runner
The pre-commit gate runs the DST and fuzz harnesses shallow. Every comment deferring depth to an out-of-band runner pointed at a script that did not exist; scripts/ held only mirror-export.sh. This is that runner.
On a ReleaseSafe build (asserts on) it runs the repostate+sync DST at a deep seed/step sweep, then every fuzz target enumerated from the runner's own usage output, so the list cannot drift, under an equal per-target wall-clock budget. --rounds grows from measured speed to fill each budget, so a cheap target sweeps millions of rounds while a pack round-trip sweeps thousands.
On the first failure it appends a runnable reproduce line to a dated log, drops a flag file, and exits non-zero; it never files and never fixes. It owns an explicit fresh seed per fuzz invocation, because the fuzz runner's buffered stderr is dropped on the error-return path: a crashing target prints only its error name, not the seed it flushed before running.
Logs and the flag live outside the repo. Installing the schedule and the first full nightly are operator actions, documented in the header and soak.README. (user: dmestas, size: 10125) [annotate] [blame] [check-ins using] [more...]
#!/usr/bin/env bash # # soak.sh — out-of-band deep DST + fuzz soak runner. # Requires bash (uses the SECONDS builtin for the per-target wall-clock budget). # # The pre-commit gate and CI run these harnesses shallow, for REGRESSION # DETECTION: `zig build dst` sweeps a few seeds x a few thousand steps, and CI # fuzzes one seed x 100 rounds. Neither is a bug hunt. This script is the bug # hunt: the same harnesses at nightly depth, on a ReleaseSafe build (asserts # ON), so machine-hours — not new invariants — do the finding. Every harness # comment in the tree that defers depth to "an out-of-band runner" means this. # # It NEVER auto-files and NEVER auto-fixes. On any failure it appends the # harness's own verbatim `reproduce with seed=...` line (reshaped into a # runnable command) to a dated log, drops a flag file the operator's next # session sees, and exits non-zero. Triage is the operator's; a soak finding # is a P6 GO/NO-GO input, not a footnote. # # --------------------------------------------------------------------------- # Usage # scripts/soak.sh # full nightly (see defaults below) # DST_SEEDS=3 DST_STEPS=500 \ # FUZZ_TARGET_BUDGET=6 FUZZ_SEEDS=3 scripts/soak.sh # bounded smoke # # Config (all env-overridable; defaults are the nightly budget): # DST_SEEDS=200 seeds swept by the repostate+sync DST # DST_STEPS=10000 steps per DST seed # FUZZ_TARGET_BUDGET=300 wall-clock seconds granted to EACH fuzz target # FUZZ_SEEDS=20 fresh-seed invocations per target (sets the slice: # slice = FUZZ_TARGET_BUDGET / FUZZ_SEEDS seconds) # FUZZ_BASE_ROUNDS=16 starting --rounds; auto-grows (x2) to fill a slice # FUZZ_MAX_ROUNDS=100000000 growth cap (one invocation never exceeds it) # ZIT_SOAK_DIR=$HOME/.zit-soak log + flag directory (kept OUT of the repo # so a soak run never shows up in `fossil status`) # # Depth-by-time, not by flat rounds: a flat --rounds would run # artifact_parse_garbage in seconds while logstore_pack_same_path_round_trip # (~276x heavier per round, see src/fuzz_tests.zig:63-78) ran for hours. Each # target instead gets an equal wall-clock budget; --rounds is grown per target # from measured speed to fill each slice, so cheap targets sweep millions of # rounds and heavy targets sweep thousands — the cost ratios honor themselves, # with no second per-target table to drift. The target LIST comes from the # runner's own usage output (the src/.github/workflows/ci.yml:146-159 pattern), # so it cannot drift either. # # --------------------------------------------------------------------------- # Operator install (NOT done by this script — installing the schedule and # running the first full nightly is an operator action): # # macOS launchd (the mac-mini runner) — write to # ~/Library/LaunchAgents/group.zit.soak.plist then `launchctl load` it: # # <?xml version="1.0" encoding="UTF-8"?> # <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" # "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> # <plist version="1.0"><dict> # <key>Label</key><string>group.zit.soak</string> # <key>ProgramArguments</key> # <array> # <string>/Users/dmestas/zit/scripts/soak.sh</string> # </array> # <key>StartCalendarInterval</key> # <dict><key>Hour</key><integer>3</integer> # <key>Minute</key><integer>0</integer></dict> # <key>StandardOutPath</key><string>/tmp/zit-soak.out</string> # <key>StandardErrorPath</key><string>/tmp/zit-soak.err</string> # </dict></plist> # # launchctl load ~/Library/LaunchAgents/group.zit.soak.plist # # cron equivalent (3am daily): # 0 3 * * * cd /Users/dmestas/zit && ./scripts/soak.sh # # After a run, check for the flag file printed on failure; triage, then delete # the flag once the finding is dispositioned. A clean run leaves any prior flag # untouched — the soak never clears a failure the operator has not yet seen. # set -eu # --- locate the repo root (parent of this script's dir) and build there ------ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_root=$(CDPATH= cd -- "${script_dir}/.." && pwd) cd "${repo_root}" # --- config ------------------------------------------------------------------ DST_SEEDS=${DST_SEEDS:-200} DST_STEPS=${DST_STEPS:-10000} FUZZ_TARGET_BUDGET=${FUZZ_TARGET_BUDGET:-300} FUZZ_SEEDS=${FUZZ_SEEDS:-20} FUZZ_BASE_ROUNDS=${FUZZ_BASE_ROUNDS:-16} FUZZ_MAX_ROUNDS=${FUZZ_MAX_ROUNDS:-100000000} ZIT_SOAK_DIR=${ZIT_SOAK_DIR:-${HOME}/.zit-soak} # ReleaseSafe: the whole point is asserts ON at depth. Never change this. OPT=-Doptimize=ReleaseSafe # per-target slice: an equal share of the target budget across FUZZ_SEEDS # fresh-seed invocations, floored at 1s so integer SECONDS math stays sane. slice=$(( FUZZ_TARGET_BUDGET / FUZZ_SEEDS )) [ "${slice}" -lt 1 ] && slice=1 mkdir -p "${ZIT_SOAK_DIR}" stamp=$(date +%Y%m%d) log="${ZIT_SOAK_DIR}/soak-${stamp}.log" flag="${ZIT_SOAK_DIR}/SOAK-FAILED" run_stderr="${ZIT_SOAK_DIR}/.soak-run.stderr" started=$(date -u +%Y-%m-%dT%H:%M:%SZ) printf '=== soak start %s (dst %sx%s, fuzz %ss/target x27) ===\n' \ "${started}" "${DST_SEEDS}" "${DST_STEPS}" "${FUZZ_TARGET_BUDGET}" >>"${log}" # fail PHASE REPRODUCE_CMD — append the verbatim reproduce line, raise the flag, # and exit non-zero. Never files, never fixes. fail() { _phase=$1 _cmd=$2 _when=$(date -u +%Y-%m-%dT%H:%M:%SZ) { printf 'FAIL %s [%s]\n' "${_phase}" "${_when}" printf 'reproduce: %s\n' "${_cmd}" printf -- '--- captured harness output (tail) ---\n' tail -n 20 "${run_stderr}" 2>/dev/null || true printf -- '---\n' } >>"${log}" { printf 'zit soak FAILED %s in %s\n' "${_when}" "${_phase}" printf 'reproduce: %s\n' "${_cmd}" printf 'log: %s\n' "${log}" } >>"${flag}" printf 'SOAK FAILED (%s). reproduce logged to %s ; flag at %s\n' \ "${_phase}" "${log}" "${flag}" >&2 exit 1 } # locate_exe NAME — echo the newest built executable NAME under the cache. # The soak runs the built binaries DIRECTLY, not through `zig build ... --`, # for two reasons: `zig build` captures and DISCARDS a failed run's child # stderr (so the harness's `reproduce with seed=...` line would be lost, the one # thing this runner exists to record), and a direct exec skips the per-round # build-graph check. `zig build` is used ONLY to compile. locate_exe() { find .zig-cache/o -type f -name "$1" -perm -u+x 2>/dev/null -print0 \ | xargs -0 ls -t 2>/dev/null | head -n 1 } # --- phase 1: the DST sweep -------------------------------------------------- printf 'soak: dst --seeds %s --steps %s (ReleaseSafe)\n' "${DST_SEEDS}" "${DST_STEPS}" >&2 # Compile the ReleaseSafe dst exe (the trivial run may exit non-zero if an # invariant is already broken; the COMPILE is what we need, so ignore its exit). zig build dst "${OPT}" -- --seeds 1 --steps 1 >/dev/null 2>&1 || true dst_exe=$(locate_exe dst) [ -z "${dst_exe}" ] && fail "dst-build" "zig build dst ${OPT} # compile failed" if "${dst_exe}" --seeds "${DST_SEEDS}" --steps "${DST_STEPS}" \ >/dev/null 2>"${run_stderr}"; then printf 'soak: dst clean (%s seeds x %s steps)\n' "${DST_SEEDS}" "${DST_STEPS}" >>"${log}" else # dst prints `reproduce with seed=N` (decimal) for both the repostate and the # sync sweep. The replay needs the same --steps, which the line omits. seed=$(grep -o 'reproduce with seed=[0-9]*' "${run_stderr}" | tail -n 1 | sed 's/.*=//') [ -z "${seed}" ] && seed='<unknown>' fail "dst" "zig build dst ${OPT} -- --seed ${seed} --steps ${DST_STEPS}" fi # --- phase 2: the fuzz sweep ------------------------------------------------- # Enumerate from the runner's own usage output (ci.yml:146-159 pattern), which # also compiles the ReleaseSafe fuzz exe. targets=$( (zig build fuzz "${OPT}" 2>&1 || true) \ | sed -n '/^targets:/,$p' | grep -E '^ [a-z0-9_]+$' | tr -d ' ' ) if [ -z "${targets}" ]; then fail "fuzz-enumerate" "zig build fuzz ${OPT} # usage format changed; sweep is vacuous" fi fuzz_exe=$(locate_exe fuzz) [ -z "${fuzz_exe}" ] && fail "fuzz-build" "zig build fuzz ${OPT} # compile failed" for target in ${targets}; do printf 'soak: fuzz %s (budget %ss, slice %ss)\n' "${target}" "${FUZZ_TARGET_BUDGET}" "${slice}" >&2 rounds=${FUZZ_BASE_ROUNDS} t_start=${SECONDS} while [ $(( SECONDS - t_start )) -lt "${FUZZ_TARGET_BUDGET}" ]; do i_start=${SECONDS} # A fresh explicit seed per invocation, drawn here (8 random bytes -> u64), # NOT left to the runner's csprng. The runner prints `reproduce with # seed=0x...` before a run, but its buffered stderr is DROPPED on the error # return path (Zig 0.16 Io): a crashing target emits only `error: X`, not # its seed. Owning the seed makes the logged reproduce line exact anyway. seed="0x$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')" if "${fuzz_exe}" "${target}" --seed "${seed}" --rounds "${rounds}" \ >/dev/null 2>"${run_stderr}"; then : else fail "fuzz:${target}" "zig build fuzz ${OPT} -- ${target} --seed ${seed} --rounds ${rounds}" fi i_dt=$(( SECONDS - i_start )) # Grow rounds (x2) toward filling a slice while an invocation still finishes # under it; measured, not guessed — the same discipline the harness uses. # Start small (FUZZ_BASE_ROUNDS) so even the heaviest target's first # invocation fits a slice, then settle where i_dt ~= slice. There is no # mid-invocation kill, so a settled invocation may overshoot the slice by up # to itself; the target budget is therefore a soft bound, honest for a soak. if [ "${i_dt}" -lt "${slice}" ] && [ "${rounds}" -lt "${FUZZ_MAX_ROUNDS}" ]; then rounds=$(( rounds * 2 )) [ "${rounds}" -gt "${FUZZ_MAX_ROUNDS}" ] && rounds=${FUZZ_MAX_ROUNDS} fi done printf 'soak: fuzz %s clean (final rounds=%s)\n' "${target}" "${rounds}" >>"${log}" done finished=$(date -u +%Y-%m-%dT%H:%M:%SZ) printf '=== soak CLEAN %s ===\n' "${finished}" >>"${log}" printf 'soak: CLEAN. log: %s\n' "${log}" >&2