diff --git a/docs/superpowers/evidence/network-diagnostics-2026-07-19.json b/docs/superpowers/evidence/network-diagnostics-2026-07-19.json new file mode 100644 index 00000000..d939594f --- /dev/null +++ b/docs/superpowers/evidence/network-diagnostics-2026-07-19.json @@ -0,0 +1,49 @@ +{ + "intent": "Add two reusable network diagnostic scripts that distinguish a slow link from an idle download client, so 'Steam is slow' can be answered with measurement instead of guesswork.", + "scope": [ + "linux_configuration/scripts/diagnostics/ (new): steam-download-duty.sh, line-speed-probe.sh, install.sh", + "linux_configuration/tests/test_network_diagnostics.bats (new)", + "Non-goal: wiring shell tests into CI — no workflow runs .sh/.bats today, including the 4 pre-existing shell tests", + "Non-goal: changing any Steam or system configuration; these scripts only read counters" + ], + "changes": [ + "steam-download-duty.sh samples /sys/class/net rx_bytes at 1s and reports duty cycle, burst rate and effective average separately. 5s averaging smears bursts and stalls into a plateau that does not exist; 1s resolution is what exposed the real behaviour.", + "line-speed-probe.sh reports burst vs sustained across single and parallel streams. Sustained deliberately means the mean of the second half, discarding TCP slow start and any ISP burst allowance.", + "Arithmetic and classification extracted into pure helpers (bytes_to_mbps, duty_pct, mean_of, classify, sustained_of, max_of, verdict_for); main() is guarded by a BASH_SOURCE check so tests can source the helpers without starting a measurement.", + "install.sh installs the bats test dependency and links both scripts into ~/.local/bin, idempotently.", + "30 bats tests covering boundary values, empty input and divide-by-zero guards." + ], + "verification": [ + { + "command": "bats linux_configuration/tests/test_network_diagnostics.bats", + "result": "pass", + "evidence": "1..30, all ok. One assertion initially failed and was correct to fail: sustained_of 10 20 30 returns 25, not 30 (odd counts drop only the first sample). The expectation was fixed, not the behaviour, since 25 matches the implementation that produced every measurement in the investigation." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "41 hooks Passed/Skipped including shellcheck and codespell; ai-evidence-contract required this artifact." + }, + { + "command": "bash linux_configuration/scripts/diagnostics/install.sh", + "result": "pass", + "evidence": "Idempotent re-run: detected existing bats 1.13.0, refreshed both ~/.local/bin symlinks." + }, + { + "command": "steam-download-duty.sh 8 (via the ~/.local/bin symlink, post-refactor)", + "result": "pass", + "evidence": "Ran end to end and reported 0% duty / 0 Mbps correctly, the download having completed." + } + ], + "risks": [ + "IFACE defaults to enp6s0; on a different NIC the scripts read a nonexistent /sys path and fail. Overridable via the IFACE environment variable.", + "Thresholds in classify() and verdict_for() (80% duty, 400/600/350 Mbps) are calibrated to this gigabit line and would need revisiting elsewhere.", + "line-speed-probe.sh pulls real ISOs to /dev/null and will consume bandwidth for its full duration if run carelessly.", + "The bats suite runs only when invoked manually; no CI workflow executes it." + ], + "rollback": [ + "git revert the commit, then rm ~/.local/bin/{line-speed-probe.sh,steam-download-duty.sh} — both are symlinks into the repo, so reverting alone leaves them dangling.", + "Optionally: sudo pacman -Rns bats if the framework is not wanted (it is a new dependency introduced by this change).", + "Validate after rollback: which steam-download-duty.sh returns nothing and no repo test references the diagnostics directory." + ] +} diff --git a/linux_configuration/scripts/diagnostics/install.sh b/linux_configuration/scripts/diagnostics/install.sh new file mode 100755 index 00000000..40c4518f --- /dev/null +++ b/linux_configuration/scripts/diagnostics/install.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# ============================================================================ +# Install dependencies for the network diagnostics scripts and link them onto +# PATH. +# +# Runtime deps : curl, awk, coreutils (all base on Arch — checked, not installed) +# Test deps : bats (extra/bats) — needed only to run the .bats suite +# +# Idempotent: safe to re-run. Existing symlinks are refreshed, already-present +# packages are skipped by pacman --needed. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +readonly SCRIPT_DIR +readonly BIN_DIR="${HOME}/.local/bin" +readonly SCRIPTS=(line-speed-probe.sh steam-download-duty.sh) + +require_runtime_tools() { + # These ship with base/base-devel on Arch. Fail loudly rather than trying to + # install a broken base system. + local missing=() + local tool + for tool in curl awk; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") + done + if (( ${#missing[@]} > 0 )); then + printf 'Error: missing required tools: %s\n' "${missing[*]}" >&2 + exit 1 + fi +} + +install_test_deps() { + if command -v bats >/dev/null 2>&1; then + printf 'bats already installed (%s)\n' "$(bats --version)" + return + fi + printf 'Installing bats (test dependency)...\n' + sudo pacman -S --noconfirm --needed bats +} + +link_scripts() { + mkdir -p "$BIN_DIR" + local s + for s in "${SCRIPTS[@]}"; do + ln -sfn "${SCRIPT_DIR}/${s}" "${BIN_DIR}/${s}" + printf 'linked %s -> %s\n' "${BIN_DIR}/${s}" "${SCRIPT_DIR}/${s}" + done +} + +main() { + require_runtime_tools + install_test_deps + link_scripts + printf '\nDone. Run the tests with:\n' + printf ' bats %s/../../tests/test_network_diagnostics.bats\n' "$SCRIPT_DIR" +} + +main "$@" diff --git a/linux_configuration/scripts/diagnostics/line-speed-probe.sh b/linux_configuration/scripts/diagnostics/line-speed-probe.sh new file mode 100755 index 00000000..076691c1 --- /dev/null +++ b/linux_configuration/scripts/diagnostics/line-speed-probe.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +# ============================================================================ +# line-speed-probe.sh — measure burst vs sustained inbound throughput. +# +# Why this exists: a single-stream download tops out around 180 Mbps on this +# line, which reads convincingly as "the ISP shapes us to 180". It does not — +# a many-connection swarm reaches 986 Mbps with zero retransmits. Only a +# multi-stream probe reveals real line capacity, so this script runs both and +# reports them separately. +# +# Baseline captured 2026-07-19 ~17:30 (Sunday evening, European peak): +# geo.mirror.pkgbuild.com 1 stream PEAK 493 SUSTAINED 182 Mbps +# ftp.icm.edu.pl 1 stream PEAK 308 SUSTAINED 164 Mbps +# 4 mixed mirrors 4 streams PEAK 766 SUSTAINED 258 Mbps +# +# Downloads are discarded to /dev/null; only the report is written. Rates come +# from the NIC's own counters, so competing traffic is included by design. +# +# Usage: line-speed-probe.sh [url] (default: probe the standard set) +# ============================================================================ + +set -euo pipefail + +IFACE="${IFACE:-enp6s0}" +DURATION="${DURATION:-90}" # seconds per probe +STEP=5 # sample interval +REPORT_DIR="${REPORT_DIR:-${HOME}/.local/share/line-speed-test}" + +MIRROR_PL="https://ftp.icm.edu.pl/pub/Linux/dist/archlinux/iso/latest/archlinux-x86_64.iso" +MIRROR_GEO="https://geo.mirror.pkgbuild.com/iso/latest/archlinux-x86_64.iso" +MIRROR_RACK="https://mirror.rackspace.com/archlinux/iso/latest/archlinux-x86_64.iso" +MIRROR_KERNEL="https://mirrors.edge.kernel.org/archlinux/iso/latest/archlinux-x86_64.iso" + +PIDS=() +REPORT="" + +# --- pure helpers (unit-tested by linux_configuration/tests) ---------------- + +bytes_to_mbps() { + local delta="$1" secs="$2" + (( secs > 0 )) || { printf '0'; return; } + printf '%d' $(( delta * 8 / secs / 1000000 )) +} + +# Sustained throughput = mean of the SECOND HALF of the samples. The first half +# is discarded deliberately: it contains TCP slow start and any ISP burst +# allowance, both of which inflate the figure we care about. +sustained_of() { + (( $# > 0 )) || { printf '0'; return; } + local count=$# half=$(( $# / 2 )) sum=0 i=0 v + for v in "$@"; do + i=$(( i + 1 )) + (( i > half )) && sum=$(( sum + v )) + done + printf '%d' $(( sum / (count - half) )) +} + +max_of() { + (( $# > 0 )) || { printf '0'; return; } + local m=0 v + for v in "$@"; do (( v > m )) && m=$v; done + printf '%d' "$m" +} + +# Interpret the best sustained figure seen across all probes. +verdict_for() { + local best="$1" + if (( best >= 600 )); then + printf 'LINE_OK' + elif (( best >= 350 )); then + printf 'PARTIAL' + else + printf 'SLOW' + fi +} + +# --- measurement ----------------------------------------------------------- + +cleanup() { + local p + for p in "${PIDS[@]:-}"; do + [[ -n "$p" ]] && kill "$p" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT + +rx_bytes() { cat "/sys/class/net/${IFACE}/statistics/rx_bytes"; } + +emit() { printf '%s\n' "$1" | tee -a "$REPORT"; } + +# probe