#!/bin/sh # The test half of `make test`, and so of CI: `.amadan/ci.d/10-gate` runs # `make test`, which runs this. Still one definition. # # What it checks is what `make test` always checked: launchctx's vet and # tests, `go vet ./...`, a repo-wide `gofmt -l .`, and every test in every # package with -count=1. What changed is the shape of the run. # # The gate's wall clock was the slowest single package, because Go runs # packages side by side but a package's own tests one after another. # internal/console took about 440 s on CI's 32 cores while most of them # sat idle, and the root package was not far behind. So: # # - vet, gofmt, launchctx and the tests all start at once instead of # vet finishing before the first test starts; # - the packages named in TEST_SPLIT have their top-level tests dealt # across several `go test -run` processes running concurrently. # # The deal is by measured weight (.amadan/test-weights.tsv, longest first # onto the least-loaded shard), so one slow test gets a shard to itself # rather than dragging its neighbours along. A test missing from the # weights file still runs; it is only dealt as if it were short. Stale # weights cost balance, never coverage. # # THE FENCE. Each split package's test list comes from `go test -list`, # every name on it goes to exactly one shard, and a shard whose -run # pattern matches nothing fails the gate. A test can therefore be added, # renamed or removed without touching this file, and no edit to the # deal can drop one silently. # # Shards are separate processes, so tests that leaned on running in the # same process as some other test (package state one test leaves for the # next) can surface here. That is a bug in the test, not in the split. set -eu : "${TEST_SPLIT=./internal/console=8 ./internal/host=4 .=5}" weights=$(pwd)/.amadan/test-weights.tsv # A shard count that is not a whole number of at least 1 would take a # package out of the ordinary run and then launch none of its shards: a # green gate that ran none of that package's tests. Refuse it up front. for spec in $TEST_SPLIT; do count=${spec#*=} case $spec:$count in *=*:[0-9]*) ;; *) echo "TEST_SPLIT entry '$spec' is not dir=count" >&2 exit 2 ;; esac case $count in *[!0-9]*) echo "TEST_SPLIT entry '$spec' needs a whole number of shards" >&2 exit 2 ;; esac if [ "$count" -lt 1 ]; then echo "TEST_SPLIT entry '$spec' needs at least one shard" >&2 exit 2 fi done # Stopping the gate stops its jobs: the go test processes and the test # binaries under them, not just the shells that started them. That covers # an interrupt, a TERM from the runner, and an early exit while jobs are # already running. Process groups (set -m) would be simpler, but dash # turns job control off without a terminal, which is how CI runs it, so # each job's tree is walked with pgrep -P instead, and the gate refuses # to start without it rather than leave orphans when it is stopped. if ! command -v pgrep >/dev/null 2>&1; then echo "pgrep is required: without it a stopped gate cannot stop its jobs" >&2 exit 2 fi out=$(mktemp -d "${TMPDIR:-/tmp}/carlos-gate-XXXXXX") # started holds name:pid for every job started; discovery is the one # foreground command in flight, if any. A job is only signalled while its # .secs file (the last thing it writes) is absent, so a finished job's # pid, which the system may since have handed to someone else's process # on this shared machine, is never touched. started="" discovery="" kill_tree() { for child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$child" done kill -TERM "$1" 2>/dev/null || true } stop_jobs() { if [ -n "$discovery" ]; then kill_tree "$discovery" fi for job in $started; do if [ ! -e "$out/${job%:*}.secs" ]; then kill_tree "${job##*:}" fi done wait 2>/dev/null || true rm -rf "$out" } trap stop_jobs EXIT trap 'trap - EXIT; stop_jobs; exit 130' INT TERM names="" # A failure found before any test runs (a package that will not list, a # deal that lost a test) is reported as a job of its own, so the jobs # already running still finish and print rather than being cut off. fail_job() { names="$names $1" printf '%s\n' "$2" >>"$out/$1.log" echo 1 >"$out/$1.rc" echo 0 >"$out/$1.secs" } start() { start_name=$1 shift names="$names $start_name" ( set +e t0=$(date +%s) "$@" >"$out/$start_name.log" 2>&1 echo $? >"$out/$start_name.rc" echo $(($(date +%s) - t0)) >"$out/$start_name.secs" ) & started="$started $start_name:$!" } # Runs one discovery command in the background and waits for it, so an # interrupt reaches the trap at once instead of after a compile. discover() { "$@" & discovery=$! discover_rc=0 wait "$discovery" || discover_rc=$? discovery="" return "$discover_rc" } start launchctx sh -c 'cd launchctx && go vet ./... && go test ./... -count=1' start vet go vet ./... start gofmt sh -c 'fmt_out=$(gofmt -l .); if [ -n "$fmt_out" ]; then echo "gofmt needed:"; echo "$fmt_out"; exit 1; fi; echo "gofmt -l .: clean"' # Everything not split runs as one ordinary `go test`. Packages are named # by import path, so match TEST_SPLIT's directories through `go list`. split_paths="" for spec in $TEST_SPLIT; do discover go list "${spec%=*}" >"$out/go-list" split_paths="$split_paths $(cat "$out/go-list")" done # go list runs on its own, not in a pipeline: a pipeline's status is its # last command's, and a go list that failed halfway would hand on a short # package list with a zero status. Each go list goes through discover, and # set -e stops the gate on its status. discover go list ./... >"$out/go-list" all=$(cat "$out/go-list") if [ -z "$all" ]; then echo "go list ./... found no packages" >&2 exit 1 fi rest="" for p in $all; do case " $split_paths " in *" $p "*) ;; *) rest="$rest $p" ;; esac done # With no packages left, go test would test the current directory instead. if [ -n "$rest" ]; then # shellcheck disable=SC2086 # one word per package, on purpose start rest go test -count=1 -timeout 30m $rest fi for spec in $TEST_SPLIT; do dir=${spec%=*} shards=${spec#*=} discover go list "$dir" >"$out/go-list" pkg=$(cat "$out/go-list") tag=$(echo "$dir" | sed 's|^\./||; s|^\.$|root|; s|/|_|g') # -list compiles the test binary, which the shards below then reuse # from the build cache. Benchmarks are listed but never run by a plain # `go test`, so they are left out of the deal. if ! discover go test -count=1 -list '.*' "$dir" >"$out/list-$tag.raw" 2>&1; then cp "$out/list-$tag.raw" "$out/$tag.log" fail_job "$tag" "could not list the tests in $pkg" continue fi grep -E '^(Test|Example|Fuzz)' "$out/list-$tag.raw" | sort -u >"$out/list-$tag" || true listed=$(wc -l <"$out/list-$tag") if [ "$listed" -eq 0 ]; then fail_job "$tag" "go test -list found no tests in $pkg, which is not believable" continue fi # Longest-processing-time first: heaviest test onto the least-loaded # shard. Unweighted tests count as 0.05 s (see test-weights.sh). awk -v pkg="$pkg" -v n="$shards" -F '\t' ' FILENAME == ARGV[1] { if ($1 == pkg) w[$2] = $3; next } { print (($0 in w) ? w[$0] : 0.05) "\t" $0 } ' "$weights" "$out/list-$tag" | sort -t "$(printf '\t')" -k1,1gr -k2,2 | awk -v n="$shards" -F '\t' ' { best = 0 for (i = 1; i < n; i++) if (load[i] < load[best]) best = i load[best] += $1 print best "\t" $2 } ' >"$out/deal-$tag" dealt=$(wc -l <"$out/deal-$tag") if [ "$dealt" -ne "$listed" ]; then fail_job "$tag" "dealt $dealt of $listed tests in $pkg" continue fi i=0 launched=0 while [ "$i" -lt "$shards" ]; do pattern=$(awk -v i="$i" -F '\t' '$1 == i { printf "%s%s", sep, $2; sep = "|" }' "$out/deal-$tag") if [ -n "$pattern" ]; then start "$tag@$i" go test -count=1 -timeout 30m -run "^($pattern)\$" "$dir" launched=$((launched + $(awk -v i="$i" -F '\t' '$1 == i' "$out/deal-$tag" | wc -l))) fi i=$((i + 1)) done if [ "$launched" -ne "$listed" ]; then fail_job "$tag-deal" "launched $launched of $listed tests in $pkg" fi done wait started="" failed="" for name in $names; do # A job with no status file was killed before it could write one. rc=$(cat "$out/$name.rc" 2>/dev/null || echo 1) echo "=== $name ($(cat "$out/$name.secs" 2>/dev/null || echo '?') s)" cat "$out/$name.log" 2>/dev/null || true case $name in *@*) if grep -q 'no tests to run' "$out/$name.log"; then echo "--- $name matched no tests; its -run pattern has drifted from the list" rc=1 fi ;; esac [ "$rc" -eq 0 ] || failed="$failed $name" done if [ -n "$failed" ]; then # The logs above can run to megabytes, and CI keeps only the tail, so # a failing test's name used to scroll out of sight (main bcae2a72, # 2026-10-25). Each failed job's verdict lines are repeated here, last: # the test names, their file:line messages, a panic, and the tests a # timeout reports as still running. echo echo "Failure summary:" for name in $failed; do echo "--- $name" grep -E '^[[:space:]]*--- FAIL|^panic:|^FAIL[[:space:]]|^[[:space:]]+[A-Za-z0-9_./-]+_test\.go:[0-9]+:|^[[:space:]]*running tests:|^[[:space:]]+Test[A-Za-z0-9_/-]* \([0-9.]+[a-z]+\)$' "$out/$name.log" 2>/dev/null | head -60 || true done echo echo "FAILED:$failed" exit 1 fi