diff --git a/docs/explanations/2026-07-19-explanation-network-diagnostics-and-stale-tests.html b/docs/explanations/2026-07-19-explanation-network-diagnostics-and-stale-tests.html new file mode 100644 index 00000000..1852f9be --- /dev/null +++ b/docs/explanations/2026-07-19-explanation-network-diagnostics-and-stale-tests.html @@ -0,0 +1,598 @@ + + + + + +Duty cycles and stale tests — 2026-07-19 + + + +
+ +

Duty cycles and stale tests

+

+ testsAndMisc · 9 commits, 836f4ef..5faed23 · 2026-07-19 · + 24 files, +1122 / −43 +

+ +
+ Contents +
    +
  1. Background — what these scripts do and why nothing tested them
  2. +
  3. Intuition — the average that described no real second
  4. +
  5. Code — the change, grouped by concern
  6. +
  7. Quiz — five questions
  8. +
+
+ +

1. Background

+ +

+ testsAndMisc is a monorepo of personal automation. The part this change touches, + linux_configuration/, holds the shell scripts that run this Arch machine: a pacman + wrapper that blocks certain packages, guards that keep /etc/hosts immutable so a + blocklist can't be edited away, a daemon that stops music playing alongside focus apps, and a + scheduler that locks the desktop at night. +

+ +

+ Alongside them sits linux_configuration/tests/ — fourteen plain-bash test scripts + with hand-rolled fail and assert_equals helpers. They look like a test + suite. They had one problem: nothing ran them. The repo's CI had two workflows, + pre-commit.yml and python-tests.yml, and neither so much as globbed for + a .sh file. The tests existed, were committed, were never executed. +

+ +

+ That matters because of what happened next. At some point scripts/ was reorganised + and most things moved under a new periodic_background/ directory. Nothing told the + tests. Four of them kept pointing at paths that no longer existed, and because no runner ever + invoked them, nobody found out. +

+ +
+ The starting question was unrelated. The session began with "why are my Steam + downloads slow?" — which produced a measurement tool, which needed tests, which needed a runner, + which is what finally executed the other fourteen. The diagnostic work and the test rot are + connected only by the CI job that ended up running both. +
+ +

The thing being measured

+ +

+ Throughput on this machine is read straight from the kernel's own byte counter, + /sys/class/net/enp6s0/statistics/rx_bytes. Sample it twice, subtract, divide by the + elapsed seconds, and you have a rate — no external tools, no forks in the loop. The only real + decision is how often you sample, and that decision turned out to be the whole story. +

+ +

2. Intuition

+ +

+ Start with the number everyone quotes: the download client said ~170 Mbps. Sampling the NIC every + five seconds agreed — a flat, believable plateau, the kind of figure you take to an ISP. +

+ +

+ Sampling the same link every one second showed something else entirely. There was no + plateau. The link alternated between multi-hundred-Mbps bursts and seconds of complete silence: +

+ +
+
One second per cell · blue = transferring, grey = idle
+
+ + + + +
+
+ Measured peak: 986 Mbps. Measured average: ~170 Mbps. + Both true. Neither describes a typical second. +
+
+ +

+ The average was arithmetically correct and descriptively useless. It reported a rate that + no individual second ever ran at. Once you can see the shape, the quantity that matters + factors cleanly: +

+ +
+
+
burst ratehow fast it moves while it is moving
+
×
+
duty cyclefraction of wall-clock spent moving
+
=
+
effective ratethe number the UI shows you
+
+
+ ~450 Mbps × 38% ≈ 170 Mbps. Two independent factors — and only one of them was the problem. +
+
+ +

+ This reframes the diagnosis. A slow burst rate means a network problem: congestion, + peering, distance. A low duty cycle means something is choosing not to transfer, and no + amount of changing servers will help. Averaging collapses both into one number and throws away + the distinction — so every remedy aimed at the wrong factor looks equally ineffective, which is + exactly how the investigation had been going. +

+ +

Play with it

+ +

+ Drag the two factors. The top strip is what 1-second sampling sees; the bars below are the same + traffic averaged into 5-second buckets — the view that hid the problem. +

+ +
+
+
+ + +
+
+ + +
+
+ +
1-second sampling — the real shape
+
+ +
5-second averaging — what the old sampling reported
+
+ +
+
—peak second
+
—effective average
+
—5s bucket spread
+
—classification
+
+ +

+ Push duty toward 100% and the two views converge — averaging is only safe when the signal is + actually steady. Drop duty low and watch the 5-second bars flatten into a plateau that no + single second matches. +

+

+
+ +

The second theme: tests that describe a world that moved

+ +

+ Running the fourteen shell tests for the first time surfaced four distinct failures, and all four + were the same bug wearing different clothes — a test still naming something by its old name: +

+ + + + + + + + + +
What the test namedWhat it had become
scripts/system-maintenance/…moved under periodic_background/
pacman_hooks_manage_hosts_guard()renamed …_guard_lib()
hosts-guard.pathreplaced by guard-file@hosts.path
grep -q 'sleep 60'improved to sleep until midnight
+ +

+ The last row is worth pausing on. That test demanded a literal sleep 60 polling loop. + The code had since been improved to sleep once, until the next day boundary, then block + on wait — strictly fewer wakeups. The test failed the code for getting better. A test + pinned to implementation text rather than the property it cares about will eventually do that. +

+ +

The failure mode worth remembering

+ +

+ One test failed in a way that looked nothing like a path bug. test_music_parallelism.sh + copies the daemon into a temporary worktree and drops a stub common.sh beside it, so + it can mock the desktop. The daemon resolves its library two levels up: +

+ +
if [[ -f "$SCRIPT_DIR/../../lib/common.sh" ]]; then
+  source "$SCRIPT_DIR/../../lib/common.sh"
+elif [[ -f "/usr/local/lib/common.sh" ]]; then   # ← the trap
+  source "/usr/local/lib/common.sh"
+ +

+ The reorganisation left the test building its worktree one directory too shallow. From there, + ../../lib/common.sh resolved outside the worktree and missed the stub — so + the elif fired and the daemon quietly loaded the real, installed + library instead. The test then ran against the live machine, ignored its own mock, and reported + expected 30, actual 15: a confident, specific, entirely fictitious daemon bug. +

+ +
+
+
worktree too shallowscripts/digital_wellbeing/
+
→
+
stub missed../../lib lands outside
+
→
+
fallback firesreal /usr/local/lib
+
→
+
wrong verdicthealthy daemon blamed
+
+
+ A fallback that exists for robustness in production became a silent correctness hole in test. +
+
+ +

+ The fix does two things, and the second matters more. It derives the worktree path from + the script's real position, so the depth can never drift again — and it makes the stub write a + marker that every run asserts, so if the wrong library is ever loaded the test says + so instead of inventing a number. Fixing the instance would have lasted until the next + reorganisation; fixing the class ends it. +

+ +

3. Code

+ +

a. Making bash unit-testable

+ +

+ The two new diagnostics live in linux_configuration/scripts/diagnostics/. Their + arithmetic — rate conversion, duty percentage, the mean of a sample window — is pulled out into + pure functions, with the measurement loop kept separate: +

+ +
bytes_to_mbps() {
+    local delta="$1" secs="$2"
+    (( secs > 0 )) || { printf '0'; return; }        # no divide-by-zero
+    printf '%d' $(( delta * 8 / secs / 1000000 ))
+}
+
+# ...
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+    main "$@"
+fi
+ +

+ That last guard is what makes the whole thing testable. Executed directly, the script runs a + measurement; sourced, it defines its helpers and stops. The bats suite sources both + scripts in setup() and asserts on the functions — no sleeping, no live NIC, no mocks. +

+ +

+ Thirty assertions cover the boundaries, and one of them earned its keep immediately: the + sustained_of test was written expecting 30 for a three-sample input and + got 25. The code was right — odd counts drop only the first sample, keeping two of + three. The expectation was wrong, and it was fixed rather than the behaviour, because 25 + is what produced every figure in the investigation. +

+ +

b. Two CI jobs, split by what they need

+ +

+ .github/workflows/shell-tests.yml runs the suite in two places, because the tests + divide cleanly: +

+ +
+
+
shell-testsubuntu-latest
6 tests + 30 bats assertions
portable
+
arch-testsarchlinux:base-devel
8 tests, root in a container
pacman · /etc · strace
+
+
+ The Arch job needs an Arch userland and root to exercise the pacman wrapper and hosts guards — + safe because the container is disposable. +
+
+ +

+ Two details were found empirically rather than assumed. archlinux:base-devel ships no + git, so actions/checkout can't clone until it's installed; and the + i3blocks test counts execve calls with strace, which needs + --cap-add=SYS_PTRACE. The whole set was validated locally in Docker — including a + --network=none run, which proves the job's dependency list is complete rather than + quietly pulling something at runtime. +

+ +

c. The stale-name repairs

+ +

+ Mechanical, once identified: four tests had their source paths corrected, three renamed symbols + were updated, and three systemd unit checks were widened to accept either generation — +

+ +
if systemctl is-active --quiet guard-file@hosts.path 2>/dev/null ||
+    systemctl is-active --quiet hosts-guard.path 2>/dev/null; then
+ +

+ — with the bind-mount check also accepting the read-only mount on /etc/hosts itself, + since the mount is the actual invariant and which unit established it is an implementation detail. +

+ +

d. A security test that works in both worlds

+ +

+ test_security_hardening.sh is really two suites in one file: seven checks read + repository files, the rest inspect a configured machine (immutable flags, systemd units, + /usr/local payloads). In a container the second group can only fail. A + LIVE_SECTION flag now marks them, and off-host they report skip instead. +

+ +
+ The near-miss. The first version detected "am I on a configured host?" with + systemctl is-system-running. That command exits non-zero when the system is + degraded — which is precisely what a failed guard unit causes. On the real machine it + concluded "not a configured host" and downgraded all four genuine failures to skips. A detector + that switches itself off exactly when something is wrong is worse than no detector at all. It now + tests for /run/systemd/system plus the installed guard payload, neither of which + cares about unit health. +
+ +

+ The safeguard against over-leniency is a negative test: faking the host markers inside a container + with no protection installed still produces ten failures. Green means protected, not permissive — + and that check is recorded in the evidence artifact as mandatory for any future change here. +

+ +

e. Outcomes over mechanisms

+ +

+ This section exists because of a wrong conclusion. Four guard units read as dead, and the obvious + reading — protection is down — was reported as such. It wasn't true. A refactor had replaced the + standalone units with templated ones, and the replacements were running the whole time. What + settled it was checking the outcome rather than the mechanism: +

+ +
lsattr /etc/hosts          →  ----i---------e-------   # immutable
+findmnt -no OPTIONS /etc/hosts  →  ro,noatime          # read-only
+systemctl is-active guard-file@hosts.path  →  active
+ +

+ Unit names are an implementation detail and they churn. The invariant — that the file cannot be + written — does not. Tests that assert the invariant survive refactors; tests that assert the + mechanism raise false alarms, and a false alarm on a security control is expensive in a way a + false alarm elsewhere is not. +

+ +

4. Quiz

+

Five questions on the reasoning, not the trivia. Click an option to reveal.

+
+
Score: 0 / 5
+ +
+ + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 7f29c990..aec4b526 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,7 +30,7 @@
testsAndMisc

Change explanations

-

13 explanations · interactive walkthroughs of notable changes, newest first.

+

14 explanations · interactive walkthroughs of notable changes, newest first.

2026-07-19 @@ -40,6 +40,10 @@ 2026-07-19 Removing the Focus-Mode Daemon + + 2026-07-19 + Duty cycles and stale tests — 2026-07-19 + 2026-07-18 Night-Lockdown: Closing the Console-Blank Race, DP Recovery, and a Wake Alarm That Actually Waits