From 836f4ef2de7cd60630bcde075c85a0cf1a9328a3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 19 Jul 2026 18:46:18 +0200 Subject: [PATCH] Add network diagnostics: duty-cycle and line-speed probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosing "Steam downloads are slow" kept pointing at the ISP because every averaged measurement showed a steady ~170 Mbps. Sampling at 1s instead of 5s showed that plateau does not exist: the link alternates between ~450 Mbps bursts and multi-second stalls. Effective rate is burst x duty_cycle, and only the second factor was wrong. steam-download-duty.sh reports those two factors separately, which is what the Steam UI cannot do. line-speed-probe.sh separates burst from sustained throughput, because a single stream tops out near 180 Mbps on this line and reads convincingly as ISP shaping when it is not — a many-connection swarm reaches 986 Mbps with zero retransmits. Arithmetic is extracted into pure helpers with main() behind a BASH_SOURCE guard so the bats suite can source them without starting a measurement. install.sh installs bats and links both scripts onto PATH. Note: no CI workflow runs .sh/.bats tests, including the four shell tests that predate this one, so the suite is manual-only for now. --- .../network-diagnostics-2026-07-19.json | 49 +++++ .../scripts/diagnostics/install.sh | 61 +++++++ .../scripts/diagnostics/line-speed-probe.sh | 168 ++++++++++++++++++ .../diagnostics/steam-download-duty.sh | 126 +++++++++++++ .../tests/test_network_diagnostics.bats | 155 ++++++++++++++++ 5 files changed, 559 insertions(+) create mode 100644 docs/superpowers/evidence/network-diagnostics-2026-07-19.json create mode 100755 linux_configuration/scripts/diagnostics/install.sh create mode 100755 linux_configuration/scripts/diagnostics/line-speed-probe.sh create mode 100755 linux_configuration/scripts/diagnostics/steam-download-duty.sh create mode 100755 linux_configuration/tests/test_network_diagnostics.bats 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