carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1#!/bin/sh
2# The test half of `make test`, and so of CI: `.amadan/ci.d/10-gate` runs
3# `make test`, which runs this. Still one definition.
4#
5# What it checks is what `make test` always checked: launchctx's vet and
6# tests, `go vet ./...`, a repo-wide `gofmt -l .`, and every test in every
7# package with -count=1. What changed is the shape of the run.
8#
9# The gate's wall clock was the slowest single package, because Go runs
10# packages side by side but a package's own tests one after another.
11# internal/console took about 440 s on CI's 32 cores while most of them
12# sat idle, and the root package was not far behind. So:
13#
14# - vet, gofmt, launchctx and the tests all start at once instead of
15# vet finishing before the first test starts;
16# - the packages named in TEST_SPLIT have their top-level tests dealt
17# across several `go test -run` processes running concurrently.
18#
19# The deal is by measured weight (.amadan/test-weights.tsv, longest first
20# onto the least-loaded shard), so one slow test gets a shard to itself
21# rather than dragging its neighbours along. A test missing from the
22# weights file still runs; it is only dealt as if it were short. Stale
23# weights cost balance, never coverage.
24#
25# THE FENCE. Each split package's test list comes from `go test -list`,
26# every name on it goes to exactly one shard, and a shard whose -run
27# pattern matches nothing fails the gate. A test can therefore be added,
28# renamed or removed without touching this file, and no edit to the
29# deal can drop one silently.
30#
31# Shards are separate processes, so tests that leaned on running in the
32# same process as some other test (package state one test leaves for the
33# next) can surface here. That is a bug in the test, not in the split.
34set -eu
35
36: "${TEST_SPLIT=./internal/console=8 ./internal/host=4 .=5}"
37weights=$(pwd)/.amadan/test-weights.tsv
38
39# A shard count that is not a whole number of at least 1 would take a
40# package out of the ordinary run and then launch none of its shards: a
41# green gate that ran none of that package's tests. Refuse it up front.
42for spec in $TEST_SPLIT; do
43 count=${spec#*=}
44 case $spec:$count in
45 *=*:[0-9]*) ;;
46 *)
47 echo "TEST_SPLIT entry '$spec' is not dir=count" >&2
48 exit 2
49 ;;
50 esac
51 case $count in
52 *[!0-9]*)
53 echo "TEST_SPLIT entry '$spec' needs a whole number of shards" >&2
54 exit 2
55 ;;
56 esac
57 if [ "$count" -lt 1 ]; then
58 echo "TEST_SPLIT entry '$spec' needs at least one shard" >&2
59 exit 2
60 fi
61done
62
63# Stopping the gate stops its jobs: the go test processes and the test
64# binaries under them, not just the shells that started them. That covers
65# an interrupt, a TERM from the runner, and an early exit while jobs are
66# already running. Process groups (set -m) would be simpler, but dash
67# turns job control off without a terminal, which is how CI runs it, so
68# each job's tree is walked with pgrep -P instead, and the gate refuses
69# to start without it rather than leave orphans when it is stopped.
70if ! command -v pgrep >/dev/null 2>&1; then
71 echo "pgrep is required: without it a stopped gate cannot stop its jobs" >&2
72 exit 2
73fi
74out=$(mktemp -d "${TMPDIR:-/tmp}/carlos-gate-XXXXXX")
75# started holds name:pid for every job started; discovery is the one
76# foreground command in flight, if any. A job is only signalled while its
77# .secs file (the last thing it writes) is absent, so a finished job's
78# pid, which the system may since have handed to someone else's process
79# on this shared machine, is never touched.
80started=""
81discovery=""
82kill_tree() {
83 for child in $(pgrep -P "$1" 2>/dev/null); do
84 kill_tree "$child"
85 done
86 kill -TERM "$1" 2>/dev/null || true
87}
88stop_jobs() {
89 if [ -n "$discovery" ]; then
90 kill_tree "$discovery"
91 fi
92 for job in $started; do
93 if [ ! -e "$out/${job%:*}.secs" ]; then
94 kill_tree "${job##*:}"
95 fi
96 done
97 wait 2>/dev/null || true
98 rm -rf "$out"
99}
100trap stop_jobs EXIT
101trap 'trap - EXIT; stop_jobs; exit 130' INT TERM
102
103names=""
104# A failure found before any test runs (a package that will not list, a
105# deal that lost a test) is reported as a job of its own, so the jobs
106# already running still finish and print rather than being cut off.
107fail_job() {
108 names="$names $1"
109 printf '%s\n' "$2" >>"$out/$1.log"
110 echo 1 >"$out/$1.rc"
111 echo 0 >"$out/$1.secs"
112}
113start() {
114 start_name=$1
115 shift
116 names="$names $start_name"
117 (
118 set +e
119 t0=$(date +%s)
120 "$@" >"$out/$start_name.log" 2>&1
121 echo $? >"$out/$start_name.rc"
122 echo $(($(date +%s) - t0)) >"$out/$start_name.secs"
123 ) &
124 started="$started $start_name:$!"
125}
126
127# Runs one discovery command in the background and waits for it, so an
128# interrupt reaches the trap at once instead of after a compile.
129discover() {
130 "$@" &
131 discovery=$!
132 discover_rc=0
133 wait "$discovery" || discover_rc=$?
134 discovery=""
135 return "$discover_rc"
136}
137
138start launchctx sh -c 'cd launchctx && go vet ./... && go test ./... -count=1'
139start vet go vet ./...
140start 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"'
141
142# Everything not split runs as one ordinary `go test`. Packages are named
143# by import path, so match TEST_SPLIT's directories through `go list`.
144split_paths=""
145for spec in $TEST_SPLIT; do
146 discover go list "${spec%=*}" >"$out/go-list"
147 split_paths="$split_paths $(cat "$out/go-list")"
148done
149# go list runs on its own, not in a pipeline: a pipeline's status is its
150# last command's, and a go list that failed halfway would hand on a short
151# package list with a zero status. Each go list goes through discover, and
152# set -e stops the gate on its status.
153discover go list ./... >"$out/go-list"
154all=$(cat "$out/go-list")
155if [ -z "$all" ]; then
156 echo "go list ./... found no packages" >&2
157 exit 1
158fi
159rest=""
160for p in $all; do
161 case " $split_paths " in
162 *" $p "*) ;;
163 *) rest="$rest $p" ;;
164 esac
165done
166# With no packages left, go test would test the current directory instead.
167if [ -n "$rest" ]; then
168 # shellcheck disable=SC2086 # one word per package, on purpose
169 start rest go test -count=1 -timeout 30m $rest
170fi
171
172for spec in $TEST_SPLIT; do
173 dir=${spec%=*}
174 shards=${spec#*=}
175 discover go list "$dir" >"$out/go-list"
176 pkg=$(cat "$out/go-list")
177 tag=$(echo "$dir" | sed 's|^\./||; s|^\.$|root|; s|/|_|g')
178
179 # -list compiles the test binary, which the shards below then reuse
180 # from the build cache. Benchmarks are listed but never run by a plain
181 # `go test`, so they are left out of the deal.
182 if ! discover go test -count=1 -list '.*' "$dir" >"$out/list-$tag.raw" 2>&1; then
183 cp "$out/list-$tag.raw" "$out/$tag.log"
184 fail_job "$tag" "could not list the tests in $pkg"
185 continue
186 fi
187 grep -E '^(Test|Example|Fuzz)' "$out/list-$tag.raw" | sort -u >"$out/list-$tag" || true
188 listed=$(wc -l <"$out/list-$tag")
189 if [ "$listed" -eq 0 ]; then
190 fail_job "$tag" "go test -list found no tests in $pkg, which is not believable"
191 continue
192 fi
193
194 # Longest-processing-time first: heaviest test onto the least-loaded
195 # shard. Unweighted tests count as 0.05 s (see test-weights.sh).
196 awk -v pkg="$pkg" -v n="$shards" -F '\t' '
197 FILENAME == ARGV[1] { if ($1 == pkg) w[$2] = $3; next }
198 { print (($0 in w) ? w[$0] : 0.05) "\t" $0 }
199 ' "$weights" "$out/list-$tag" | sort -t "$(printf '\t')" -k1,1gr -k2,2 |
200 awk -v n="$shards" -F '\t' '
201 {
202 best = 0
203 for (i = 1; i < n; i++) if (load[i] < load[best]) best = i
204 load[best] += $1
205 print best "\t" $2
206 }
207 ' >"$out/deal-$tag"
208
209 dealt=$(wc -l <"$out/deal-$tag")
210 if [ "$dealt" -ne "$listed" ]; then
211 fail_job "$tag" "dealt $dealt of $listed tests in $pkg"
212 continue
213 fi
214
215 i=0
216 launched=0
217 while [ "$i" -lt "$shards" ]; do
218 pattern=$(awk -v i="$i" -F '\t' '$1 == i { printf "%s%s", sep, $2; sep = "|" }' "$out/deal-$tag")
219 if [ -n "$pattern" ]; then
220 start "$tag@$i" go test -count=1 -timeout 30m -run "^($pattern)\$" "$dir"
221 launched=$((launched + $(awk -v i="$i" -F '\t' '$1 == i' "$out/deal-$tag" | wc -l)))
222 fi
223 i=$((i + 1))
224 done
225 if [ "$launched" -ne "$listed" ]; then
226 fail_job "$tag-deal" "launched $launched of $listed tests in $pkg"
227 fi
228done
229
230wait
231started=""
232
233failed=""
234for name in $names; do
235 # A job with no status file was killed before it could write one.
236 rc=$(cat "$out/$name.rc" 2>/dev/null || echo 1)
237 echo "=== $name ($(cat "$out/$name.secs" 2>/dev/null || echo '?') s)"
238 cat "$out/$name.log" 2>/dev/null || true
239 case $name in
240 *@*)
241 if grep -q 'no tests to run' "$out/$name.log"; then
242 echo "--- $name matched no tests; its -run pattern has drifted from the list"
243 rc=1
244 fi
245 ;;
246 esac
247 [ "$rc" -eq 0 ] || failed="$failed $name"
248done
249
250if [ -n "$failed" ]; then
251 # The logs above can run to megabytes, and CI keeps only the tail, so
252 # a failing test's name used to scroll out of sight (main bcae2a72,
253 # 2026-10-25). Each failed job's verdict lines are repeated here, last:
254 # the test names, their file:line messages, a panic, and the tests a
255 # timeout reports as still running.
256 echo
257 echo "Failure summary:"
258 for name in $failed; do
259 echo "--- $name"
260 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
261 done
262 echo
263 echo "FAILED:$failed"
264 exit 1
265fi
266