Files
shater/scripts/run-tests.sh
T
omarandClaude Opus 5 4078334d85 fix(stats,alert,panel): put a ceiling on everything that only grew
Four maps had no bound on a box with 512 MB that runs for months. The health
board only ever inserted — the delete exists but no path in this fork calls it —
and it lives on the engine context, so it outlives every generation. Its keys are
node tags, and providers rename nodes on each subscription refresh: about 440k
keys a year, some 88 MB. Alert dedup keyed on MAC with no delete at all. The
stats aggregator's server and outbound counters were the only ones with no cap,
no prune and no top-N, and one of them was handed to the panel whole on every
poll.

They are bounded now, evicting least-recently-seen, with numbers argued from this
box rather than round: the board holds 4096 against a live generation of about
1200 tags, so a rename day cannot evict a tag still in use. Nothing is dropped
silently — the same rule the log sink already follows — and a new Dropped section
in the snapshot reports all six bounded aggregates, including the three that had
been evicting without saying so.

Snapshot did O(devices × domains) under the aggregator lock, sorting five
thousand entries to show fifteen, and could read the DHCP lease file from inside
it. Meanwhile the event subscribers have 64-slot buffers that drop without a
counter, so an open Overview page cost the query log real rows. Selection is
top-K now — proven byte-identical to the old sort over 200 random trials — and
both the lease read and the row ordering happen outside the lock.

The panel server had one timeout, on headers. An unauthenticated client could
hold a goroutine, a socket and a descriptor forever by sending its body one byte
at a time; a stopped reader on the log stream held the handler, the pipe and a
child process that outlived the request. Every phase is bounded now, with the
unauthenticated route on a tighter budget than the rest, and the log stream
renewing its deadline per chunk so a slow-but-reading client is never truncated.

And the last of the detour transports: each call built a fresh one, and the alert
delivery path dropped it, pinning keep-alive sessions through the engine's own
outbounds for 90 seconds — eighteen times the budget a retiring generation gets.

The race skip is gone from the gate. The test it existed for raced in its own
clock, not in the product; that is fixed, so nothing is excluded under -race any
more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:40:21 +03:00

250 lines
10 KiB
Bash

#!/usr/bin/env bash
#
# run-tests.sh — THE test gate of the release tract.
#
# WHY THIS EXISTS (2026-07-26)
# Until now the release tract ran almost no tests. The only `go test` calls in
# the whole publishing path were scripts/build-shaterd.sh's one-package
# buildtags check and the three named tests scripts/check-router-tags.sh runs.
# The upstream .github/workflows/test.yml triggers on `stable`/`testing`/
# `unstable` — branches this fork does not have — and Gitea does not read
# .github/workflows at all once .gitea/workflows exists. Net effect: 115 of the
# 116 test files under shater/** had never executed in CI, and
# TestDNSFilterRemoteBlocklistHTTPClient shipped red through two releases
# before anyone ran it by hand.
#
# WHAT IT GUARANTEES
# 1. The suite runs under the SHIPPED build tags (scripts/router-tags.sh), not
# under some CI-local tag set. This is not cosmetic: the AmneziaWG tests in
# transport/wireguard are `//go:build with_awg` — 1 test file compiles
# without the tag set, 7 with it. The 2026-07-25 WireGuard outage was
# exactly a "built with X, verified with Y" gap.
# 2. It runs on linux. shater/generate has 44 test files on linux against 32 on
# windows/darwin; the linux-only half is where the routing, ruleset, DNS and
# health tests live.
# 3. Nothing is skipped SILENTLY. Two machine checks:
# - the tag set may only ADD test files, never hide them (a test behind
# `//go:build !with_awg` would vanish from the gate — this fails first);
# - every package that has tests must report `ok` by name; a suite that
# compiles down to "no test files" fails the gate instead of passing it.
# A guard that silently runs nothing is worse than no guard (same rule as
# scripts/check-router-tags.sh).
#
# Usage:
# scripts/run-tests.sh # full gate (~3 min warm on the runner)
# scripts/run-tests.sh --no-race # skip the -race pass (faster; local loop)
#
# Env:
# SHATER_GO_IMAGE docker image used to reach linux from a non-linux host
# (default golang:1.26 — keep it >= go.mod's toolchain).
# SHATER_NO_DOCKER=1 fail instead of falling back to docker.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO"
RACE=1
for a in "$@"; do
case "$a" in
--no-race) RACE=0 ;;
-h|--help) sed -n '2,41p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "run-tests: unknown flag: $a" >&2; exit 2 ;;
esac
done
# shellcheck source=router-tags.sh
. "$SCRIPT_DIR/router-tags.sh"
# The fork's own trees plus the upstream trees the fork edits (adapter/, route/,
# option/, dns/ all carry shater changes). ROOTS, not a hand-kept package list: a
# new package with tests joins the gate the moment it is created, which is the
# whole point.
ROOTS=(./shater/... ./protocol/... ./transport/... ./adapter/... ./route/... ./option/... ./dns/...)
# common/ is mostly upstream, but common/tls, common/tlsfragment, common/sniff
# and common/urltest carry fork behaviour (D13 DPI-bypass, urltest health), so it
# is in — minus the privileged integration tests below.
ROOTS_COMMON=(./common/...)
# SKIP, WITH REASON: common/tlsspoof's TestIntegration* enter TCP_REPAIR and
# need CAP_NET_ADMIN. The act_runner job container runs as root but WITHOUT
# that capability, so they do not skip — they FAIL. Excluded by name so the
# rest of common/ can be a real gate instead of a permanently red one. (On
# linux every tlsspoof test is a TestIntegration*, so that package is
# effectively uncovered here; it is covered by the VM runs.)
SKIP_COMMON='^TestIntegration'
# SKIP, WITH REASON: the first -race run over this tree (2026-07-26 — nobody
# had ever run one) turned up two failures. One was a REAL product race:
# ClientBind.connect() touched its fields from both the Send() path and
# RoutineReceiveIncoming() with no lock, caught by
# transport/wireguard.TestAwgDetourClientBindDelivers; that one has since been
# fixed in client_bind.go and is NOT skipped — it is exactly what this pass is
# for. What was left:
# - shater/alert.TestExpiryDedupWithinDay — the test's own closure read a
# variable the test body wrote while Notifier.dispatch's goroutine was
# still delivering. FIXED 2026-07-26 (the simulated clock now has a mutex),
# so the entry is gone and the -race pass covers the whole tree again.
# Nothing is skipped under -race any more. Keep it that way: an entry here is a
# hole in the gate, so add one only with a named reason and delete it the moment
# the race is fixed.
RACE_SKIP='^$'
echo "== shater test gate =="
echo " tags : $SHATER_ROUTER_TAGS"
echo " ldflags: $SHATER_ROUTER_LDFLAGS"
echo " race : $([ "$RACE" -eq 1 ] && echo yes || echo no)"
echo
# --- linux, or re-exec on linux ---------------------------------------------
# The linux-only half of the suite is the half worth running (see header). From a
# non-linux host, re-exec inside a golang container rather than quietly testing
# 32 of shater/generate's 44 files — a partial gate reads exactly like a passing
# one.
if [ "$(go env GOOS)" != "linux" ] && [ "${SHATER_TESTS_IN_DOCKER:-0}" != "1" ]; then
if [ "${SHATER_NO_DOCKER:-0}" = "1" ] || ! command -v docker >/dev/null 2>&1; then
echo " ERROR: the gate needs linux (GOOS=$(go env GOOS)) and docker is unavailable/disabled." >&2
echo " Run it on the linux CI runner or the OpenWrt VM." >&2
exit 1
fi
image="${SHATER_GO_IMAGE:-golang:1.26}"
echo "== re-exec on linux via docker ($image) =="
host_repo="$REPO"
command -v cygpath >/dev/null 2>&1 && host_repo="$(cygpath -w "$REPO")"
MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 docker run --rm \
-v "$host_repo":/src \
-v shater-tagcheck-gomod:/go/pkg/mod \
-v shater-tagcheck-gocache:/root/.cache/go-build \
-w /src \
-e SHATER_TESTS_IN_DOCKER=1 \
"$image" bash scripts/run-tests.sh "$@"
exit $?
fi
ALL_ROOTS=("${ROOTS[@]}" "${ROOTS_COMMON[@]}")
# --- [1/4] the tag set may only ADD test files, never hide them --------------
# `go list` counts the test files the compiler would actually take. If adding the
# shipped tags REMOVES a test file from any package, that test exists but the
# gate would never see it — which is the failure mode this whole script is about,
# just pointed the other way.
echo "== [1/4] no test file is hidden by the shipped tag set =="
LISTFMT='{{.ImportPath}} {{len .TestGoFiles}} {{len .XTestGoFiles}}'
plain="$(go list -f "$LISTFMT" "${ALL_ROOTS[@]}")"
tagged="$(go list -tags "$SHATER_ROUTER_TAGS" -f "$LISTFMT" "${ALL_ROOTS[@]}")"
hidden=0
while read -r pkg t x; do
[ -n "${pkg:-}" ] || continue
n_plain=$((t + x))
[ "$n_plain" -gt 0 ] || continue
line="$(awk -v p="$pkg" '$1 == p { print; exit }' <<<"$tagged")"
if [ -z "$line" ]; then
echo " HIDDEN: $pkg has $n_plain test file(s) untagged but no package at all under the shipped tags" >&2
hidden=1
continue
fi
read -r _ tt tx <<<"$line"
n_tagged=$((tt + tx))
if [ "$n_tagged" -lt "$n_plain" ]; then
echo " HIDDEN: $pkg — $n_plain test file(s) untagged, only $n_tagged under the shipped tags" >&2
hidden=1
elif [ "$n_tagged" -gt "$n_plain" ]; then
echo " +$((n_tagged - n_plain)) tag-gated test file(s): $pkg ($n_plain -> $n_tagged)"
fi
done <<<"$plain"
if [ "$hidden" -ne 0 ]; then
echo >&2
echo " FAILED: a test file is invisible to the tag set we ship. Either the" >&2
echo " constraint is wrong or the tag set is — do not paper over it" >&2
echo " by testing with different tags than we build with." >&2
exit 1
fi
echo
# --- the runner --------------------------------------------------------------
# Runs one suite and then PROVES it ran: every package `go list` says has tests
# must appear as `ok <pkg>` in the output. `go test` over a package whose tests
# all vanished behind a build constraint prints "[no test files]" and exits 0 —
# a green run that verified nothing.
LOG="$(mktemp)"
trap 'rm -f "$LOG"' EXIT
FAILED=0
run_suite() { # $1=label $2=extra go-test flags (may be empty) $3..=packages
local label="$1" extra="$2"
shift 2
local pkgs=("$@") rc=0 expect missing=0 pkg
expect="$(go list -tags "$SHATER_ROUTER_TAGS" \
-f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' \
"${pkgs[@]}" | grep -v '^$' || true)"
if [ -z "$expect" ]; then
echo " FAILED [$label]: go list reports no package with tests here — the gate" >&2
echo " would have run nothing and passed." >&2
FAILED=1
return
fi
echo " packages with tests: $(wc -l <<<"$expect" | tr -d ' ')"
set +e
# shellcheck disable=SC2086 # $extra is a deliberate word-split flag list
go test -count=1 $extra \
-tags "$SHATER_ROUTER_TAGS" -ldflags "$SHATER_ROUTER_LDFLAGS" \
"${pkgs[@]}" >"$LOG" 2>&1
rc=$?
set -e
sed 's/^/ /' "$LOG"
if [ "$rc" -ne 0 ]; then
echo " FAILED [$label]: go test exited $rc" >&2
FAILED=1
return
fi
while read -r pkg; do
[ -n "$pkg" ] || continue
grep -qE "^ok[[:space:]]+$pkg([[:space:]]|\$)" "$LOG" || {
echo " DID NOT RUN [$label]: $pkg" >&2
missing=1
}
done <<<"$expect"
if [ "$missing" -ne 0 ]; then
echo " FAILED [$label]: package(s) above have test files but produced no 'ok'" >&2
echo " line. Build-constraint or file-name drift emptied them." >&2
FAILED=1
return
fi
echo " OK [$label]"
}
# --- [2/4] the fork's trees, shipped tags, linux -----------------------------
echo "== [2/4] go test — the fork's trees (shipped tags, linux) =="
run_suite main "" "${ROOTS[@]}"
echo
# --- [3/4] common/, minus the tests that need CAP_NET_ADMIN ------------------
echo "== [3/4] go test — common/ (minus the CAP_NET_ADMIN integration tests) =="
run_suite common "-skip $SKIP_COMMON" "${ROOTS_COMMON[@]}"
echo
# --- [4/4] -race over the same trees -----------------------------------------
# Everything, not a subset: shater/netplane alone is ~110 s under -race and it is
# the single most concurrency-critical package we own (the nft data plane), so
# once it is in, adding the rest costs ~40 s more. common/ is left out — it is
# upstream code exercised by upstream CI.
if [ "$RACE" -eq 1 ]; then
echo "== [4/4] go test -race — the fork's trees =="
echo " nothing is skipped under -race"
run_suite race "-race -skip $RACE_SKIP" "${ROOTS[@]}"
else
echo "== [4/4] -race pass skipped (--no-race) =="
fi
echo
if [ "$FAILED" -ne 0 ]; then
echo "== TEST GATE FAILED — nothing may be published from this run. ==" >&2
exit 1
fi
echo "== OK: the shipped tag set, on linux, passes every test we own. =="