Sync config from arch

- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
This commit is contained in:
asepharyana committed 2026-09-23 15:19:12 +07:00
commit 1cdb82a76f
300 files changed
+78143

No files matched your search

@@ -0,0 +1,16 @@
fl=00f000
h=speed.cloudflare.com
ip=198.51.100.7
ts=1788316831.000
visit_scheme=https
uag=curl/8.21.0
colo=XXX
sliver=005-tier3
http=http/1.1
loc=XX
tls=TLSv1.3
sni=plaintext
warp=off
gateway=off
rbi=off
kex=X25519MLKEM768
@@ -0,0 +1,11 @@
1788278079.141547: wlo1: del station 02:11:22:33:44:01
1788278079.142551: wlo1 (phy #0): deauth 02:11:22:33:44:01 -> 02:aa:bb:cc:dd:ee reason 8: Disassociated because sending station is leaving (or has left) the BSS
1788278079.142608: wlo1 (phy #0): disconnected (by AP) reason: 8: Disassociated because sending station is leaving (or has left) the BSS
1788278079.250838: wlo1 (phy #0): scan started
1788278082.019194: wlo1 (phy #0): scan finished: 2412 2417 2422 2427 2432 2437 2442 2447 2452 2457 2462 2467 2472 5180 5200 5220 5240 5260 5280 5300 5320 5500 5520 5540 5560 5580 5600 5620 5640 5660 5680 5700 5720 5745 5765 5785 5805 5825 5845 5865 5955 5975 5995 6015 6035 6055 6075 6095 6115 6135 6155 6175 6195 6215 6235 6255 6275 6295 6315 6335 6355 6375 6395 6415, "x3me" ""
1788278082.068166: wlo1: new station 02:11:22:33:44:05
1788278082.070230: wlo1 (phy #0): auth 02:11:22:33:44:05 -> 02:aa:bb:cc:dd:ee status: 0: Successful
1788278082.079172: wlo1 (phy #0): assoc 02:11:22:33:44:05 -> 02:aa:bb:cc:dd:ee status: 0: Successful
1788278082.079235: wlo1 (phy #0): connected to 02:11:22:33:44:05
1788278082.101842: wlo1 (phy #0): ctrl. port TX status (cookie 70f): acked
1788278082.104043: wlo1 (phy #0): ctrl. port TX status (cookie 710): acked
@@ -0,0 +1,11 @@
Connected to b4:a2:5c:19:d6:20 (on wlo1)
SSID: Excitel
freq: 5180.0
RX: 3863830534 bytes (50083937 packets)
TX: 548710762 bytes (4490767 packets)
signal: -64 dBm
rx bitrate: 309.7 MBit/s 40MHz HE-MCS 6 HE-NSS 2 HE-GI 0 HE-DCM 0
tx bitrate: 458.8 MBit/s 40MHz HE-MCS 9 HE-NSS 2 HE-GI 0 HE-DCM 0
bss flags: short-slot-time
dtim period: 1
beacon int: 100
@@ -0,0 +1,8 @@
PING 10.10.0.253 (10.10.0.253) 56(84) bytes of data.
[1787562369.690501] no answer yet for icmp_seq=1
[1787562370.195259] no answer yet for icmp_seq=2
[1787562370.698418] no answer yet for icmp_seq=3
[1787562371.202303] no answer yet for icmp_seq=4
[1787562371.707355] no answer yet for icmp_seq=5
[1787562372.221354] no answer yet for icmp_seq=5
[1787562372.290416] From 10.10.0.147 icmp_seq=1 Destination Host Unreachable
@@ -0,0 +1,6 @@
PING 10.10.0.1 (10.10.0.1) 56(84) bytes of data.
[1787562260.703963] 64 bytes from 10.10.0.1: icmp_seq=1 ttl=64 time=9.13 ms
[1787562261.204546] 64 bytes from 10.10.0.1: icmp_seq=2 ttl=64 time=9.00 ms
[1787562261.707487] 64 bytes from 10.10.0.1: icmp_seq=3 ttl=64 time=11.3 ms
[1787562262.201694] 64 bytes from 10.10.0.1: icmp_seq=4 ttl=64 time=4.38 ms
[1787562262.702621] 64 bytes from 10.10.0.1: icmp_seq=5 ttl=64 time=4.17 ms
@@ -0,0 +1,14 @@
ESTAB 0 0 192.0.2.10:36900 198.51.100.7:443 users:(("good-a",pid=1001,fd=10))
cubic rto:213 rtt:20.0/5.0 mss:1238 bytes_sent:40000 bytes_acked:40001 bytes_received:120000 minrtt:10.0
ESTAB 0 0 192.0.2.10:36901 198.51.100.7:443 users:(("good-b",pid=1002,fd=11))
cubic rto:213 rtt:34.0/6.0 mss:1238 bytes_sent:9000 bytes_acked:9001 bytes_received:31000 minrtt:14.0
ESTAB 0 0 192.0.2.10:36902 198.51.100.7:443 users:(("good-c",pid=1003,fd=12))
cubic rto:213 rtt:120.5/9.0 mss:1238 bytes_sent:8000 bytes_retrans:2400 bytes_acked:7600 bytes_received:90000 minrtt:100.5
ESTAB 0 0 192.0.2.10:36903 198.51.100.7:443 users:(("tiny",pid=1004,fd=13))
cubic rto:213 rtt:9.0/2.0 mss:1238 bytes_sent:500 bytes_acked:501 bytes_received:900 minrtt:5.0
ESTAB 0 0 192.0.2.10:36904 198.51.100.7:443 users:(("no-timing",pid=1005,fd=14))
cubic rto:213 mss:1238 bytes_sent:50000 bytes_acked:50001 bytes_received:70000
ESTAB 0 0 192.0.2.10:36905 198.51.100.7:443 users:(("implausible",pid=1006,fd=15))
cubic rto:213 rtt:99999.0/500.0 mss:1238 bytes_sent:60000 bytes_acked:60001 bytes_received:80000 minrtt:12000.0
ESTAB 0 0 192.0.2.10:36906 198.51.100.7:443 users:(("inverted",pid=1007,fd=16))
cubic rto:213 rtt:11.0/3.0 mss:1238 bytes_sent:70000 bytes_acked:70001 bytes_received:90000 minrtt:40.0
@@ -0,0 +1,8 @@
ESTAB 0 0 192.0.2.10:36900 198.51.100.7:443 users:(("chrome",pid=4958,fd=66))
cubic wscale:7,10 rto:213 rtt:12.8/10.5 mss:1238 bytes_sent:328028 bytes_acked:328029 bytes_received:2724692 segs_out:1205 segs_in:2905 minrtt:8.412
ESTAB 0 0 192.0.2.10:54430 203.0.113.9:443 users:(("slack",pid=7753,fd=28))
cubic wscale:12,10 rto:232 rtt:31.2/4.5 mss:1238 bytes_sent:14099 bytes_retrans:108 bytes_acked:13992 bytes_received:65451 minrtt:29.004
ESTAB 0 0 192.0.2.10:39590 198.51.100.7:443 users:(("chrome",pid=4958,fd=159))
cubic wscale:7,10 rto:216 rtt:15.0/12.1 mss:1238 bytes_sent:4858 bytes_acked:4859 bytes_received:8142 minrtt:6.55
ESTAB 0 0 192.0.2.10:41000 203.0.113.20:22
cubic rto:210 rtt:10/5 mss:1400 bytes_sent:999 bytes_received:888
@@ -0,0 +1,115 @@
// Nothing pathspark draws may land outside the canvas it was given.
//
// Two geometry defects shipped from this file in one day: the ring on the
// wrong slot (0.2.39) and the ring drawn half outside the right edge
// (0.2.41). Both were invisible to every other check in the battery —
// qmllint does not evaluate it, and the Python suite cannot reach it.
// `draw()` is pure, so a fake 2d context can.
//
// Run by test/test_pathspark.py, which skips when node is absent.
const fs = require("fs");
const path = require("path");
const src = fs.readFileSync(
path.join(__dirname, "..", "pathspark.js"), "utf8");
const Spark = new Function(
src + "\nreturn { draw: draw, slots: slots, sharedMax: sharedMax };")();
function recorder(w, h) {
const marks = [];
let lw = 1, cur = null;
const note = (what, x0, y0, x1, y1) =>
marks.push({ what, x0, y0, x1, y1 });
return {
marks,
set lineWidth(v) { lw = v; },
get lineWidth() { return lw; },
set strokeStyle(v) {}, set fillStyle(v) {},
set lineJoin(v) {}, set lineCap(v) {}, set globalAlpha(v) {},
clearRect() {},
beginPath() { cur = []; },
moveTo(x, y) { cur.push([x, y]); },
lineTo(x, y) { cur.push([x, y]); },
arc(x, y, r) { cur.push(["arc", x, y, r]); },
stroke() {
for (const p of cur || []) {
if (p[0] === "arc") {
const [, x, y, r] = p;
note("ring", x - r - lw / 2, y - r - lw / 2,
x + r + lw / 2, y + r + lw / 2);
} else {
note("line", p[0] - lw / 2, p[1] - lw / 2,
p[0] + lw / 2, p[1] + lw / 2);
}
}
cur = null;
},
fill() {
for (const p of cur || []) {
if (p[0] === "arc") {
const [, x, y, r] = p;
note("dot", x - r, y - r, x + r, y + r);
}
}
cur = null;
},
fillRect(x, y, rw, rh) { note("bar", x, y, x + rw, y + rh); },
};
}
const opts = (phase, live, motion) => ({
max: 50, phase, live, motion,
colorFor: () => "#9ece6a", downColor: "#f7768e", dimColor: "#565f89",
});
const N = 36;
const cases = {
"all at the top of the scale": Array(N).fill({ v: 50 }),
"all at zero": Array(N).fill({ v: 0 }),
"over the scale max": Array(N).fill({ v: 5000 }),
"all down": Array(N).fill({ down: true }),
"all gaps": Array(N).fill(null),
"newest is down": Array(N).fill({ v: 5 }).map((p, i) =>
i >= N - 3 ? { down: true } : p),
"newest is a gap": Array(N).fill({ v: 5 }).map((p, i) =>
i === N - 1 ? null : p),
"one lone measured slot": Array(N).fill(null).map((p, i) =>
i === N - 1 ? { v: 50 } : p),
"alternating gaps": Array(N).fill(null).map((p, i) =>
i % 2 ? { v: 50 } : null),
};
let checks = 0, bad = [];
for (const [w, h] of [[120, 22], [60, 22], [200, 22], [120, 16], [40, 12]]) {
for (const [name, series] of Object.entries(cases)) {
for (const phase of [0, 0.25, 0.5, 0.75, 1]) {
for (const [live, motion] of [[true, true], [true, false], [false, false]]) {
const ctx = recorder(w, h);
Spark.draw(ctx, w, h, series, opts(phase, live, motion));
for (const m of ctx.marks) {
checks++;
const out = m.x0 < -0.01 || m.y0 < -0.01
|| m.x1 > w + 0.01 || m.y1 > h + 0.01;
if (out) {
bad.push(`${w}x${h} ${name} phase=${phase} motion=${motion}: `
+ `${m.what} spans x[${m.x0.toFixed(2)},${m.x1.toFixed(2)}] `
+ `y[${m.y0.toFixed(2)},${m.y1.toFixed(2)}]`);
}
}
}
}
}
}
if (!checks) {
console.error("the harness drew nothing — it is not testing anything");
process.exit(1);
}
const seen = [...new Set(bad)];
if (seen.length) {
console.error(`${seen.length} marks outside the canvas:`);
for (const b of seen.slice(0, 12)) console.error(" " + b);
process.exit(1);
}
console.log(`ok — ${checks} marks, all inside the canvas`);
@@ -0,0 +1,67 @@
"""Shared fixtures for the nexthopd tests.
Fixtures under fixtures/ are recorded from a real Arch laptop (ping from
iputils, iw 6.x) — the formats these parsers exist to survive. The ss
fixture is synthetic (RFC 5737 addresses).
Run everything: python3 -m unittest discover -s test
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
FIXTURES = Path(__file__).parent / "fixtures"
REPO = Path(__file__).resolve().parent.parent
def run_inline(fn):
"""A synchronous spawn for watchers that run their check off the loop."""
fn()
def run_now(fn):
"""A spawn for tests: run the check inline so its result lands this tick."""
fn()
def _st(count=60, loss=0.0, p50=20.0, p95=30.0):
return {"count": count, "loss": loss, "p50": p50, "p95": p95}
class FakeStore:
def __init__(self):
self.opened = []
self.closed = []
def open_event(self, ts, kind, sev, leg, detail):
self.opened.append((kind, sev, leg, detail))
return len(self.opened)
def close_event(self, event_id, ts):
self.closed.append(event_id)
class StubEvents:
"""Stands in for NlEvents: answers cause_for() with a fixed cause."""
def __init__(self, cause=None, raise_=False):
self.cause, self.raise_, self.calls = cause, raise_, []
def cause_for(self, bssid, now, window):
self.calls.append((bssid, now, window))
if self.raise_:
raise RuntimeError("boom")
return self.cause
class _FakeDaemonForDisruption:
"""Just enough of Daemon to exercise record_disruption."""
def __init__(self, store):
self.store = store
from nexthopd.daemon import Daemon as _D
record_disruption = _D.record_disruption
del _D
@@ -0,0 +1,244 @@
"""Tests for apps.py — ss parsing, per-app attribution, kernel socket timing, the bounded read.
Run: python3 -m unittest discover -s test
"""
import subprocess
import sys
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd.apps import ( # noqa: E402
AppTraffic,
Sock,
latency_stats,
parse_ss,
socket_timing)
from support import FIXTURES # noqa: E402
class AppAttribution(unittest.TestCase):
def fixture(self):
return (FIXTURES / "ss-tinp.txt").read_text()
def test_parse_ss(self):
socks = parse_ss(self.fixture())
# The unattributed ssh socket (no users:()) is skipped.
self.assertEqual(len(socks), 3)
apps = sorted({v[0] for v in socks.values()})
self.assertEqual(apps, ["chrome", "slack"])
chrome = [v for v in socks.values() if v[0] == "chrome"]
self.assertEqual(sum(v[3] for v in chrome), 2724692 + 8142)
def test_rates_are_deltas_not_lifetimes(self):
t = AppTraffic()
base = parse_ss(self.fixture())
t._fold(base, 100.0)
# Baseline sample must not count connection lifetimes as traffic.
self.assertEqual(t.rates, [])
grown = {k: v._replace(sent=v.sent + 1000, recv=v.recv + 3000)
for k, v in base.items()}
t._fold(grown, 103.0)
by_name = {a["name"]: a for a in t.rates}
self.assertAlmostEqual(by_name["chrome"]["rx_bps"], 2 * 3000 / 3, delta=1)
self.assertEqual(by_name["chrome"]["conns"], 2)
self.assertEqual(by_name["slack"]["rx_total"], 3000)
# ------------------------------------------------- kernel socket timing
def guards(self):
return (FIXTURES / "ss-rtt-guards.txt").read_text()
def test_parse_ss_reads_kernel_timing(self):
socks = parse_ss(self.fixture())
slack = next(v for v in socks.values() if v.app == "slack")
self.assertEqual(slack.srtt, 31.2)
self.assertEqual(slack.minrtt, 29.004)
self.assertEqual(slack.retrans, 108)
# A socket the kernel has not timed reports None, never zero — zero
# would read as "instant", which is the opposite of "unknown".
untimed = parse_ss(
'ESTAB 0 0 192.0.2.10:1 198.51.100.7:443 users:(("x",pid=9,fd=1))\n'
'\t cubic bytes_sent:99999 bytes_received:99999\n')
self.assertIsNone(next(iter(untimed.values())).srtt)
self.assertIsNone(next(iter(untimed.values())).minrtt)
def test_latency_stats_from_fixture(self):
st = latency_stats(parse_ss(self.fixture()))
self.assertEqual(st["sockets"], 3)
self.assertEqual(st["rejected"], 0)
self.assertEqual(st["rtt_p50"], 15.0)
self.assertEqual(st["floor_p50"], 8.41)
# Queueing is the median of 12.8-8.412, 31.2-29.004, 15.0-6.55.
self.assertEqual(st["queue_p50"], 4.39)
self.assertEqual(st["retrans_sockets"], 1)
def test_queueing_divides_out_distance(self):
"""The point of the metric: a far socket and a near one with the
same queueing must report the same queueing."""
near = Sock("near", 1, 50_000, 50_000, srtt=15.0, minrtt=5.0)
far = Sock("far", 2, 50_000, 50_000, srtt=310.0, minrtt=300.0)
self.assertEqual(socket_timing(near)[2], socket_timing(far)[2])
st = latency_stats({"a": near, "b": far,
"c": Sock("mid", 3, 50_000, 50_000,
srtt=60.0, minrtt=50.0)})
self.assertEqual(st["queue_p50"], 10.0)
# ...while the raw round trips stay far apart, as they should.
self.assertEqual(st["rtt_p50"], 60.0)
self.assertEqual(st["floor_p50"], 50.0)
def test_guards_reject_implausible_and_thin_sockets(self):
socks = parse_ss(self.guards())
self.assertEqual(len(socks), 7)
st = latency_stats(socks)
# good-a, good-b, good-c qualify.
self.assertEqual(st["sockets"], 3)
# tiny (under the byte floor), implausible (past the ceiling) and
# inverted (floor above the average) are rejected...
self.assertEqual(st["rejected"], 3)
# ...but the socket the kernel simply has not timed is NOT counted as
# a rejection: absent evidence is not bad evidence.
untimed = [v for v in socks.values() if v.srtt is None]
self.assertEqual(len(untimed), 1)
self.assertEqual(st["retrans_sockets"], 1)
def test_each_guard_individually(self):
ok = Sock("ok", 1, 50_000, 50_000, srtt=20.0, minrtt=10.0)
self.assertIsNotNone(socket_timing(ok))
# Below the byte floor the path's own minimum is not trustworthy.
self.assertIsNone(socket_timing(ok._replace(sent=100, recv=100)))
# Plausibility ceiling: a broken measurement, not a slow link.
self.assertIsNone(socket_timing(ok._replace(srtt=99_999.0)))
# A floor above the average cannot happen; the field is stale.
self.assertIsNone(socket_timing(ok._replace(minrtt=40.0)))
# Rounding in `ss` output must not trip the inversion check.
self.assertIsNotNone(socket_timing(ok._replace(srtt=20.0, minrtt=20.02)))
# Zero or missing timing yields nothing rather than a zero RTT.
self.assertIsNone(socket_timing(ok._replace(srtt=0.0)))
self.assertIsNone(socket_timing(ok._replace(minrtt=None)))
def test_under_sampled_publishes_nothing(self):
two = {"a": Sock("a", 1, 50_000, 50_000, srtt=20.0, minrtt=10.0),
"b": Sock("b", 2, 50_000, 50_000, srtt=22.0, minrtt=11.0)}
# Two qualifying sockets is not a distribution.
self.assertIsNone(latency_stats(two))
three = dict(two, c=Sock("c", 3, 50_000, 50_000, srtt=24.0, minrtt=12.0))
self.assertIsNotNone(latency_stats(three))
self.assertIsNone(latency_stats({}))
def test_per_app_timing_medians(self):
t = AppTraffic()
base = parse_ss(self.guards())
t._fold(base, 100.0)
t._fold({k: v._replace(sent=v.sent + 500, recv=v.recv + 500)
for k, v in base.items()}, 103.0)
by_name = {a["name"]: a for a in t.rates}
self.assertEqual(by_name["good-a"]["rtt_ms"], 20.0)
self.assertEqual(by_name["good-a"]["queue_ms"], 10.0)
self.assertEqual(by_name["good-c"]["queue_ms"], 20.0)
# An app whose sockets all failed the guard reports no timing at all
# rather than a fabricated zero.
self.assertIsNone(by_name["tiny"]["rtt_ms"])
self.assertIsNone(by_name["no-timing"]["rtt_ms"])
self.assertIsNone(by_name["inverted"]["queue_ms"])
# The aggregate rides along on the same fold.
self.assertEqual(t.latency["sockets"], 3)
def test_idle_apps_report_no_timing(self):
t = AppTraffic()
base = parse_ss(self.fixture())
t._fold(base, 100.0)
t._fold(base, 103.0)
t._fold({}, 106.0)
idle = {a["name"]: a for a in t.top()}
# No live socket means no measurement, not a stale one.
self.assertIsNone(idle["chrome"]["rtt_ms"])
self.assertIsNone(idle["chrome"]["queue_ms"])
def test_socket_cap_bounds_parsing(self):
# Thousands of distinct sockets parse to at most the cap.
lines = []
for i in range(3000):
lines.append(
'ESTAB 0 0 192.0.2.10:%d 198.51.100.7:443 '
'users:(("app%d",pid=%d,fd=4))' % (10000 + i, i % 7, 100 + i))
lines.append('\t cubic bytes_sent:100 bytes_received:200')
socks = parse_ss("\n".join(lines), max_sockets=50)
self.assertEqual(len(socks), 50)
def test_new_socket_counts_whole_life(self):
t = AppTraffic()
t._fold({}, 100.0)
t._fold(parse_ss(self.fixture()), 103.0)
by_name = {a["name"]: a for a in t.rates}
# Born between samples: its full counters are this interval's traffic.
self.assertEqual(by_name["slack"]["rx_total"], 65451)
class SubprocessBounds(unittest.TestCase):
def test_ss_read_gives_up_at_the_deadline_and_reaps(self):
from nexthopd.apps import read_bounded
proc = subprocess.Popen(
[sys.executable, "-c",
"import sys, time; sys.stdout.write('abc'); sys.stdout.flush();"
" time.sleep(30)"], stdout=subprocess.PIPE)
t0 = time.monotonic()
self.assertIsNone(read_bounded(proc, 4096, 0.3))
# The deadline bounds the CALL. The old 3.0 here was slack hiding
# the fact that reaping ran on its own clock afterwards.
self.assertLess(time.monotonic() - t0, 0.3 + 0.4)
self.assertIsNotNone(proc.returncode) # reaped, not a zombie
def test_reaping_a_child_that_will_not_die_still_returns_the_loop(self):
"""`ss` in uninterruptible sleep must not hold the daemon's loop.
This is the case the budget exists for: terminate ignored, kill
not collectable. Before the budget was shared, reaping spent a
flat 2 s here on top of a deadline that had already expired, so
the loop could block for the deadline plus 2.1 s — past the age
at which the bar declares the daemon dead.
"""
from nexthopd.apps import _reap
class Undying:
returncode = None
def poll(self):
return None
def terminate(self):
pass
def kill(self):
pass
def wait(self, timeout=None):
time.sleep(timeout)
raise subprocess.TimeoutExpired("ss", timeout)
t0 = time.monotonic()
_reap(Undying(), 0.4)
self.assertLess(time.monotonic() - t0, 0.4 + 0.3)
def test_ss_read_is_capped_and_still_reaps(self):
from nexthopd.apps import read_bounded
proc = subprocess.Popen(
[sys.executable, "-c", "import sys; sys.stdout.write('x' * 200000)"],
stdout=subprocess.PIPE)
out = read_bounded(proc, 1000, 5.0)
self.assertEqual(len(out), 1000)
self.assertIsNotNone(proc.returncode)
def test_ss_read_returns_complete_output(self):
from nexthopd.apps import read_bounded
proc = subprocess.Popen(
[sys.executable, "-c", "print('line1'); print('line2')"],
stdout=subprocess.PIPE)
self.assertEqual(read_bounded(proc, 4096, 5.0), "line1\nline2\n")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,161 @@
"""Tests for cli.py — the two signal paths and what authorizes them.
Run: python3 -m unittest discover -s test
"""
import os
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from support import REPO # noqa: E402
class RetireAuthorization(unittest.TestCase):
"""The guard that stands between a version mismatch and a SIGTERM.
It used to be a shell one-liner that could not be tested; it passed a
NUL to `tr`, so execve truncated the script and no daemon was ever
actually retired. These cases pin each fact it checks.
"""
def setUp(self):
from nexthopd.cli import authorized_to_retire
self.auth = authorized_to_retire
from nexthopd.daemon import proc_start_ticks
self.ticks = proc_start_ticks
def spawn(self, args, cwd=None):
import subprocess
p = subprocess.Popen(args, cwd=cwd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
self.addCleanup(lambda: (p.kill(), p.wait()))
time.sleep(0.4)
return p
def daemon_shaped(self):
"""A process whose argv is exactly `python -m nexthopd`.
A stand-in rather than the real daemon: the real one would lose the
flock race against whatever is already running and exit before the
guard could look at it, and a test has no business probing the
network. The guard reads argv, owner and start time — all of which
this reproduces exactly.
"""
d = tempfile.mkdtemp()
self.addCleanup(lambda: __import__("shutil").rmtree(d, True))
pkg = Path(d) / "nexthopd"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "__main__.py").write_text("import time\ntime.sleep(30)\n")
return self.spawn([sys.executable, "-m", "nexthopd"], cwd=d)
def test_daemon_argv_with_matching_start_is_authorized(self):
p = self.daemon_shaped()
self.assertTrue(self.auth(p.pid, self.ticks(p.pid)))
def test_wrong_start_time_refused(self):
p = self.daemon_shaped()
self.assertFalse(self.auth(p.pid, self.ticks(p.pid) + 1))
def test_zero_start_skips_only_the_time_check(self):
# live.json from a daemon too old to publish pid_start: argv and
# ownership still have to hold.
p = self.daemon_shaped()
self.assertTrue(self.auth(p.pid, 0))
def test_other_python_process_refused(self):
# Same interpreter, different module: never ours to signal.
p = self.spawn([sys.executable, "-c", "import time; time.sleep(30)"])
self.assertFalse(self.auth(p.pid, 0))
def test_lookalike_argv_refused(self):
# A process that merely mentions nexthopd is not the daemon.
p = self.spawn([sys.executable, "-c",
"import time; time.sleep(30) # -m nexthopd"])
self.assertFalse(self.auth(p.pid, 0))
def test_extra_arguments_refused(self):
p = self.spawn([sys.executable, "-m", "nexthopd.cli", "stream", "live"])
self.assertFalse(self.auth(p.pid, 0))
def test_pid_that_does_not_exist_refused(self):
self.assertFalse(self.auth(999999, 0))
def test_pid_one_refused(self):
# Owned by root, so the ownership check alone stops us.
self.assertFalse(self.auth(1, 0))
def test_command_string_carries_no_nul(self):
# The regression itself: the argv QML hands to sh must survive
# execve, which a NUL byte would truncate.
import subprocess
cmd = ('cd "$1" && exec python3 -m nexthopd.cli retire '
'--pid "$2" --start "$3"')
self.assertNotIn("\0", cmd)
r = subprocess.run(["sh", "-c", cmd, "sh", str(REPO), "999999", "0"],
capture_output=True)
self.assertEqual(r.returncode, 1) # refused, not a syntax error
self.assertEqual(r.stderr, b"")
class PeakSignalAuthorization(unittest.TestCase):
"""`nexthop peak` signals only a verified lock holder. SIGUSR1's default
disposition is terminate, so the wrong pid is not a harmless miss."""
def setUp(self):
import contextlib, io
from nexthopd import cli, paths
self.dir = tempfile.TemporaryDirectory()
os.environ["XDG_STATE_HOME"] = self.dir.name
self.cli = cli
self.lock = str(paths.lock_path())
os.makedirs(os.path.dirname(self.lock), mode=0o700, exist_ok=True)
self.killed = []
self._kill = os.kill
os.kill = lambda pid, sig: self.killed.append((pid, sig))
self._quiet = contextlib.redirect_stdout(io.StringIO())
self._quiet.__enter__()
def tearDown(self):
self._quiet.__exit__(None, None, None)
os.kill = self._kill
del os.environ["XDG_STATE_HOME"]
self.dir.cleanup()
def test_a_lock_nobody_holds_is_not_a_daemon(self):
# A dead daemon's pid, recycled by a live process — this one.
Path(self.lock).write_text(str(os.getpid()))
self.assertEqual(self.cli.cmd_peak(None), 1)
self.assertEqual(self.killed, [])
def test_a_held_lock_still_needs_the_holders_identity(self):
import fcntl
# Hold the lock ourselves: our argv is the test runner, not
# `python -m nexthopd`, so the identity check must refuse it.
fd = os.open(self.lock, os.O_RDWR | os.O_CREAT, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX)
os.write(fd, str(os.getpid()).encode())
try:
self.assertEqual(self.cli.cmd_peak(None), 1)
self.assertEqual(self.killed, [])
finally:
os.close(fd)
def test_a_symlink_at_the_lock_path_is_refused(self):
os.symlink("/proc/self/stat", self.lock)
self.assertEqual(self.cli.cmd_peak(None), 1)
self.assertEqual(self.killed, [])
def test_missing_lock_file_is_not_a_daemon(self):
self.assertEqual(self.cli.cmd_peak(None), 1)
self.assertEqual(self.killed, [])
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,252 @@
"""Tests for instruments.py — ranking, the bench, the merged view and the equal-weight fold.
Run: python3 -m unittest discover -s test
"""
import sys
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd import score # noqa: E402
from nexthopd.instruments import Bench, MergedSeries, penalty # noqa: E402
from nexthopd.probes import Series # noqa: E402
from support import _st # noqa: E402
class InstrumentRanking(unittest.TestCase):
def test_loss_dominates_then_spread_then_median(self):
clean = penalty(_st(loss=0.0, p50=20, p95=30))
lossy = penalty(_st(loss=0.05, p50=10, p95=12))
wobbly = penalty(_st(loss=0.0, p50=20, p95=200))
slower = penalty(_st(loss=0.0, p50=60, p95=70))
self.assertLess(clean, lossy) # 5% loss loses to 10 ms spread
self.assertLess(clean, wobbly) # tail spread beats nothing
self.assertLess(clean, slower) # median only as tiebreak
self.assertLess(slower, wobbly) # 40 ms slower < 170 ms wobblier
def test_too_few_samples_judge_nothing(self):
self.assertIsNone(penalty(_st(count=Bench.MIN_SAMPLES - 1)))
self.assertIsNone(penalty(None))
self.assertIsNone(penalty({}))
def test_full_loss_is_dead_not_slow(self):
dead = penalty({"count": 60, "loss": 1.0, "p50": None, "p95": None})
self.assertGreaterEqual(dead, Bench.DEAD_AT)
class BenchSeats(unittest.TestCase):
POOL = [("icmp-a", "icmp", "1.1.1.1"), ("tcp-a", "tcp", "1.1.1.1:443"),
("tcp-b", "tcp", "cf:443"), ("tcp-c", "tcp", "google:443")]
def bench(self):
return Bench(self.POOL)
def keys(self, b):
return sorted(i.key for i in b.actives())
def test_the_opening_pair_spans_two_hosts(self):
"""Not `pool[:2]`, which was one host wearing two protocols.
`icmp-a` and `tcp-a` both point at 1.1.1.1, so seating both put every
scored instrument on one address before any evidence existed. On a
network that blocks it, that is a false outage at 4 s and a desktop
notification at 5 s on every daemon start, with no way back until the
bench's next pass a minute later (#5).
"""
self.assertEqual(self.keys(self.bench()), ["icmp-a", "tcp-b"])
def test_a_blocked_anchor_still_leaves_a_working_seat(self):
"""The property the pairing exists for.
One live instrument is enough: the leg answers if either does, so no
outage is declared while the bench re-ranks.
"""
b = self.bench()
hosts = {i.target.rpartition(":")[0] or i.target for i in b.actives()}
self.assertEqual(len(hosts), 2)
anchor_seats = [i for i in b.actives() if i.target.startswith("1.1.1.1")]
self.assertEqual(len(anchor_seats), 1)
def test_a_pool_with_one_host_still_fills_both_seats(self):
# Fewer scored instruments than the bench expects is the worse
# failure, so a pool that cannot offer two hosts falls back to order.
b = Bench([("icmp-a", "icmp", "1.1.1.1"), ("tcp-a", "tcp", "1.1.1.1:443")])
self.assertEqual(self.keys(b), ["icmp-a", "tcp-a"])
def test_dead_seat_is_replaced_immediately(self):
b = self.bench()
stats = {"icmp-a": _st(), "tcp-b": _st(loss=1.0, p50=None, p95=None),
"tcp-a": _st(p50=25, p95=35), "tcp-c": _st(p50=40, p95=60)}
changes = b.evaluate(1000.0, stats)
self.assertEqual(sorted(changes), [("tcp-a", True), ("tcp-b", False)])
self.assertEqual(self.keys(b), ["icmp-a", "tcp-a"])
def test_no_churn_during_a_full_outage(self):
b = self.bench()
dead = _st(loss=1.0, p50=None, p95=None)
stats = {k: dict(dead) for k in ("icmp-a", "tcp-a", "tcp-b", "tcp-c")}
self.assertEqual(b.evaluate(1000.0, stats), [])
self.assertEqual(self.keys(b), ["icmp-a", "tcp-b"])
def test_challenger_needs_two_consecutive_clear_wins(self):
b = self.bench()
# tcp-a is 20%+ better than the worst seat; one win is not enough.
stats = {"icmp-a": _st(p50=10, p95=14), "tcp-b": _st(p50=100, p95=160),
"tcp-a": _st(p50=20, p95=24), "tcp-c": _st(p50=90, p95=150)}
self.assertEqual(b.evaluate(1000.0, stats), [])
changes = b.evaluate(1000.0 + Bench.RESELECT_EVERY_S, stats)
self.assertEqual(sorted(changes), [("tcp-a", True), ("tcp-b", False)])
self.assertEqual(self.keys(b), ["icmp-a", "tcp-a"])
def test_a_win_streak_broken_starts_over(self):
b = self.bench()
better = {"icmp-a": _st(p50=10, p95=14), "tcp-b": _st(p50=100, p95=160),
"tcp-a": _st(p50=20, p95=24), "tcp-c": _st(p50=90, p95=150)}
level = {"icmp-a": _st(p50=10, p95=14), "tcp-b": _st(p50=19, p95=24),
"tcp-a": _st(p50=20, p95=24), "tcp-c": _st(p50=90, p95=150)}
t = 1000.0
self.assertEqual(b.evaluate(t, better), [])
self.assertEqual(b.evaluate(t + 300, level), []) # streak broken
self.assertEqual(b.evaluate(t + 600, better), []) # back to one win
self.assertEqual(self.keys(b), ["icmp-a", "tcp-b"])
def test_flapping_instrument_is_quarantined(self):
b = self.bench()
inst = b.instruments["tcp-a"]
t = 1000.0
# Three seat changes inside an hour is a flap.
for _, active in (("tcp-a", True), ("tcp-a", False), ("tcp-a", True)):
b._seat(inst, t, active)
t += 60
self.assertGreater(inst.quarantined_until, t)
# While quarantined it cannot be promoted, even over a corpse.
b._seat(inst, t, False)
stats = {"icmp-a": _st(), "tcp-b": _st(loss=1.0, p50=None, p95=None),
"tcp-a": _st(p50=5, p95=6), "tcp-c": _st(p50=40, p95=60)}
b.evaluate(t + 60, stats)
self.assertEqual(self.keys(b), ["icmp-a", "tcp-c"])
def test_snapshot_names_the_seats(self):
b = self.bench()
snap = b.snapshot(1000.0, {"icmp-a": _st(p50=7)})
by_key = {row["key"]: row for row in snap}
self.assertTrue(by_key["icmp-a"]["active"])
self.assertEqual(by_key["icmp-a"]["p50"], 7)
self.assertFalse(by_key["tcp-c"]["active"])
self.assertEqual(len(snap), 4)
class MergedView(unittest.TestCase):
def test_merges_and_orders_the_seated_series(self):
from nexthopd.probes import Series
a, c = Series(), Series()
now = time.time()
a.add(now - 3, 10.0)
c.add(now - 2, None)
a.add(now - 1, 12.0)
m = MergedSeries(lambda: [a, c])
self.assertEqual([s[1] for s in m.since(10)], [10.0, None, 12.0])
self.assertEqual(len(m.all()), 3)
m2 = MergedSeries(lambda: [a])
self.assertEqual(len(m2.since(10)), 2)
class EqualWeightMerge(unittest.TestCase):
"""The scored internet leg is two instruments. Each must count once
whatever its cadence, and jitter must never be measured across them."""
@staticmethod
def stream(value_fn, interval, span=30.0, offset=0.0, now=None):
now = now or time.time()
s = Series()
t, i = now - span + offset, 0
while t <= now:
s.add(t, value_fn(i), False)
t += interval
i += 1
return s
def test_cadence_cannot_move_the_statistics(self):
from nexthopd.instruments import merged_stats
now = time.time()
merged, pooled = set(), set()
for icmp_iv in (0.2, 0.5, 1.0):
a = self.stream(lambda i: 5.0, icmp_iv, now=now)
b = self.stream(lambda i: 15.0, 1.0, offset=0.25, now=now)
st = merged_stats([a.since(30), b.since(30)])
merged.add((st["p50"], st["p75"], st["p95"], st["jitter"], st["loss"]))
# The pooled stream, which is what used to be scored.
old = Series.stats(MergedSeries(lambda: [a, b]).since(30))
pooled.add((old["p75"], old["jitter"]))
self.assertEqual(len(merged), 1, merged)
self.assertEqual(merged.pop(), (5.0, 15.0, 15.0, 0.0, 0.0))
# ...whereas the setting alone used to move p75 and jitter.
self.assertGreater(len(pooled), 1, pooled)
def test_jitter_never_crosses_instruments(self):
from nexthopd.instruments import merged_stats
now = time.time()
steady_a = self.stream(lambda i: 5.0, 0.5, now=now)
steady_b = self.stream(lambda i: 15.0, 1.0, offset=0.25, now=now)
self.assertEqual(merged_stats([steady_a.since(30),
steady_b.since(30)])["jitter"], 0.0)
# Two streams with no jitter read as jittery when pooled: the defect.
self.assertGreater(Series.stats(
MergedSeries(lambda: [steady_a, steady_b]).since(30))["jitter"], 5.0)
# An instrument that really alternates contributes its own IPDV,
# averaged with the steady one's zero.
swinging = self.stream(lambda i: 10.0 if i % 2 else 40.0, 1.0, now=now)
st = merged_stats([swinging.since(30), steady_a.since(30)])
self.assertEqual(st["jitter"], 15.0)
def test_loss_is_the_mean_of_the_instruments_loss_rates(self):
from nexthopd.instruments import merged_stats
now = time.time()
lossy = self.stream(lambda i: None if i % 10 == 0 else 5.0, 0.5, now=now)
clean = self.stream(lambda i: 15.0, 1.0, offset=0.25, now=now)
a, b = lossy.since(30), clean.since(30)
a_loss = sum(1 for s in a if s[1] is None) / len(a)
st = merged_stats([a, b])
self.assertAlmostEqual(st["loss"], a_loss / 2, places=9)
# Not the count-weighted figure the pool gave, which the faster
# instrument dominated.
self.assertNotAlmostEqual(st["loss"], a_loss * len(a) / (len(a) + len(b)),
places=3)
def test_one_instrument_is_series_stats_exactly(self):
from nexthopd.instruments import merged_stats
a = self.stream(lambda i: 5.0 + (i % 3), 0.5)
self.assertEqual(merged_stats([a.since(30)]), Series.stats(a.since(30)))
# A seated instrument with nothing yet does not dilute the other.
self.assertEqual(merged_stats([a.since(30), []]), Series.stats(a.since(30)))
self.assertEqual(merged_stats([])["count"], 0)
def test_this_line_as_built(self):
"""ICMP 3.41 ms at 500 ms and TCP 4.82 ms at 1 s — live.json's own
figures on 2026-09-08. Two stable instruments: no jitter, and Lag
is the slower instrument's round trip."""
from nexthopd.instruments import merged_stats
now = time.time()
a = self.stream(lambda i: 3.41, 0.5, now=now)
b = self.stream(lambda i: 4.82, 1.0, offset=0.25, now=now)
# lag_ms rounds to a tenth: the slower instrument's 4.82 and nothing added.
self.assertEqual(score.lag_ms(merged_stats([a.since(30), b.since(30)])), 4.8)
# The pool manufactured 0.93 ms of jitter and 1.4 ms of Lag.
old = score.lag_ms(Series.stats(MergedSeries(lambda: [a, b]).since(30)))
self.assertAlmostEqual(old, 6.2, delta=0.1)
def test_merged_series_stats_is_the_equal_weight_fold(self):
from nexthopd.instruments import merged_stats
a = self.stream(lambda i: 5.0, 0.5)
b = self.stream(lambda i: 15.0, 1.0, offset=0.25)
m = MergedSeries(lambda: [a, b])
self.assertEqual(m.stats(30), merged_stats([a.since(30), b.since(30)]))
self.assertEqual(len(m.each(30)), 2)
self.assertEqual(len(m.each()), 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,358 @@
"""Tests for linkevents.py and LinkWatch — who ended the association.
Run: python3 -m unittest discover -s test
"""
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd.daemon import LinkWatch # noqa: E402
from nexthopd.linkevents import NlEvents, reason_text # noqa: E402
from nexthopd.store import Store # noqa: E402
from support import StubEvents # noqa: E402
class LinkEvents(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.store = Store(Path(self.dir.name) / "t.db")
self.watch = LinkWatch(self.store)
def tearDown(self):
self.store.close()
self.dir.cleanup()
def kinds(self):
return [e["kind"] for e in self.store.events()]
def test_first_sighting_is_not_an_association(self):
# A daemon restart sees an existing link — that is not an event.
t = time.time()
self.watch.sample(t, {"bssid": "aa:aa:aa:aa:aa:aa", "ssid": "Office"})
self.assertEqual(self.kinds(), [])
def test_roam_and_detail(self):
t = time.time()
self.watch.sample(t, {"bssid": "aa:aa:aa:aa:aa:aa", "ssid": "Office",
"channel": 149, "signal_dbm": -61})
self.watch.sample(t + 2, {"bssid": "bb:bb:bb:bb:bb:bb", "ssid": "Office",
"channel": 44, "signal_dbm": -47})
kinds = self.kinds()
self.assertIn("roam", kinds)
self.assertNotIn("associate", kinds)
roam = [e for e in self.store.events() if e["kind"] == "roam"][0]
self.assertIn("149", roam["detail"])
self.assertIn("44", roam["detail"])
self.assertIn("-61", roam["detail"])
def test_associate_needs_a_confirmed_gap(self):
t = time.time()
link = {"bssid": "aa:aa:aa:aa:aa:aa", "ssid": "Office"}
self.watch.sample(t, link)
# A brief iw hiccup (fewer empty reads than the threshold) is not a
# disassociation, so the recovery is not an association.
for i in range(LinkWatch.GAP_SAMPLES - 1):
self.watch.sample(t + 2 + i * 2, {})
self.watch.sample(t + 20, link)
self.assertEqual(self.kinds(), [])
# A confirmed gap is, and the recovery is logged once.
for i in range(LinkWatch.GAP_SAMPLES):
self.watch.sample(t + 30 + i * 2, {})
self.watch.sample(t + 60, link)
self.assertEqual(self.kinds(), ["associate"])
BUSY = 1_000_000 # bytes/sec, well above the idle floor
def test_rate_drop_needs_sustain(self):
t = time.time()
link = {"bssid": "aa:aa:aa:aa:aa:aa", "ssid": "X", "channel": 44}
for i in range(5):
self.watch.sample(t + i * 2, dict(link, tx_mbps=500), self.BUSY)
# A momentary dip is not an event.
self.watch.sample(t + 12, dict(link, tx_mbps=120), self.BUSY)
self.watch.sample(t + 14, dict(link, tx_mbps=500), self.BUSY)
self.assertNotIn("rate-drop", self.kinds())
# A sustained one is, and it closes with the recovery.
for i in range(8):
self.watch.sample(t + 20 + i * 2, dict(link, tx_mbps=110), self.BUSY)
self.assertIn("rate-drop", self.kinds())
self.watch.sample(t + 40, dict(link, tx_mbps=480), self.BUSY)
drop = [e for e in self.store.events() if e["kind"] == "rate-drop"][0]
self.assertIsNotNone(drop["ended_ts"])
self.assertIn("110", drop["detail"])
def test_idle_rate_drops_are_not_events(self):
# Power save renegotiates a low bitrate the moment the link idles;
# with no traffic that is invisible to the user and must not log.
t = time.time()
link = {"bssid": "aa:aa:aa:aa:aa:aa", "ssid": "X", "channel": 44}
for i in range(5):
self.watch.sample(t + i * 2, dict(link, tx_mbps=2000), self.BUSY)
for i in range(20):
self.watch.sample(t + 20 + i * 2, dict(link, tx_mbps=120), 500)
self.assertNotIn("rate-drop", self.kinds())
def test_throttled_to_one_hz(self):
t = time.time()
self.watch.sample(t, {"bssid": "aa:aa:aa:aa:aa:aa", "ssid": "X"})
# Two samples inside the same second: the second is ignored, so a
# bssid flap faster than 1 Hz cannot spam the log.
self.watch.sample(t + 0.4, {"bssid": "bb:bb:bb:bb:bb:bb", "ssid": "X"})
self.assertNotIn("roam", self.kinds())
class LinkAttribution(unittest.TestCase):
"""A BSSID change says who ended the previous association."""
OLD = "aa:aa:aa:aa:aa:aa"
NEW = "bb:bb:bb:bb:bb:bb"
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.store = Store(Path(self.dir.name) / "t.db")
def tearDown(self):
self.store.close()
self.dir.cleanup()
@staticmethod
def cause(by_ap, reason, gap_s):
return {"by_ap": by_ap, "reason": reason, "gap_s": gap_s,
"frame": "deauth", "t": 0.0}
def change(self, cause, raise_=False):
events = StubEvents(cause, raise_)
watch = LinkWatch(self.store, events)
t = time.time()
watch.sample(t, {"bssid": self.OLD, "ssid": "Office",
"channel": 149, "signal_dbm": -30})
watch.sample(t + 2, {"bssid": self.NEW, "ssid": "Office",
"channel": 13, "signal_dbm": -53})
evs = self.store.events()
self.assertEqual(len(evs), 1)
return evs[0], events
def test_ap_deauth_is_a_kick(self):
e, events = self.change(self.cause(True, 2, 3.0))
self.assertEqual(e["kind"], "kick")
self.assertIn("Kicked by AP " + self.OLD, e["detail"])
self.assertIn("reason 2: previous authentication no longer valid",
e["detail"])
self.assertIn("rejoined via " + self.NEW + " after 3 s", e["detail"])
self.assertIn("channel 149 \u2192 13", e["detail"])
self.assertIn("-30 \u2192 -53 dBm", e["detail"])
# It asked about the AP we left, over a window reaching back to
# when that link was last seen up.
bssid, now, window = events.calls[0]
self.assertEqual(bssid, self.OLD)
self.assertGreaterEqual(window, 2.0)
def test_local_deauth_with_instant_reauth_is_a_roam(self):
# mac80211 emits the deauth from inside the call that starts the
# new authentication, so a roam's gap is milliseconds.
e, _ = self.change(self.cause(False, 1, 0.004))
self.assertEqual(e["kind"], "roam")
self.assertTrue(e["detail"].startswith("Roamed to " + self.NEW))
def test_local_deauth_with_a_scan_first_is_a_drop(self):
e, _ = self.change(self.cause(False, 4, 2.8))
self.assertEqual(e["kind"], "drop")
self.assertIn("Dropped by this machine (reason 4: beacon loss)",
e["detail"])
self.assertIn("rejoined via " + self.NEW + " after 3 s", e["detail"])
def test_unknown_cause_stays_a_roam(self):
e, _ = self.change(None)
self.assertEqual(e["kind"], "roam")
self.assertTrue(e["detail"].startswith("Roamed to " + self.NEW))
def test_without_an_event_source_nothing_changes(self):
watch = LinkWatch(self.store)
t = time.time()
watch.sample(t, {"bssid": self.OLD, "ssid": "Office"})
watch.sample(t + 2, {"bssid": self.NEW, "ssid": "Office"})
self.assertEqual([e["kind"] for e in self.store.events()], ["roam"])
def test_attribution_failure_never_costs_the_event(self):
e, _ = self.change(self.cause(True, 2, 3.0), raise_=True)
self.assertEqual(e["kind"], "roam")
def test_kick_with_a_confirmed_gap_is_one_row(self):
# Kicked, off the air long enough to count as disassociated, back
# on the same radio: one row says all of that, not a bare
# "Associated with".
events = StubEvents(self.cause(True, 2, None))
watch = LinkWatch(self.store, events)
t = time.time()
link = {"bssid": self.OLD, "ssid": "Office"}
watch.sample(t, link)
for i in range(LinkWatch.GAP_SAMPLES):
watch.sample(t + 2 + i * 2, {})
watch.sample(t + 14, link)
evs = self.store.events()
self.assertEqual([e["kind"] for e in evs], ["kick"])
self.assertIn("rejoined after 14 s", evs[0]["detail"])
self.assertNotIn(" via ", evs[0]["detail"])
# The lookup window reached back to the last time the link was up.
self.assertGreaterEqual(events.calls[-1][2], 14.0)
def test_gap_nobody_claimed_is_a_plain_association(self):
watch = LinkWatch(self.store, StubEvents(None))
t = time.time()
link = {"bssid": self.OLD, "ssid": "Office"}
watch.sample(t, link)
for i in range(LinkWatch.GAP_SAMPLES):
watch.sample(t + 2 + i * 2, {})
watch.sample(t + 14, link)
self.assertEqual([e["kind"] for e in self.store.events()], ["associate"])
class NlEventParsing(unittest.TestCase):
"""`iw event -t` lines become causes; everything else is ignored."""
AP = "02:11:22:33:44:05"
ME = "02:aa:bb:cc:dd:ee"
NEW = "02:11:22:33:44:09"
def feed(self, lines):
ev = NlEvents()
for line in lines:
ev.consume(line)
return ev
def test_ap_kick_then_rejoin_after_a_scan(self):
ev = self.feed([
"1000.000100: wlo1 (phy #0): deauth %s -> %s reason 2: "
"Previous authentication no longer valid" % (self.AP, self.ME),
"1000.000200: wlo1 (phy #0): disconnected (by AP) reason: 2: "
"Previous authentication no longer valid",
"1000.100000: wlo1 (phy #0): scan started",
'1002.900000: wlo1 (phy #0): scan finished: 2412 2437, ""',
"1003.000100: wlo1 (phy #0): auth %s -> %s status: 0: Successful"
% (self.NEW, self.ME),
"1003.010000: wlo1 (phy #0): assoc %s -> %s status: 0: Successful"
% (self.NEW, self.ME),
"1003.012000: wlo1 (phy #0): connected to %s" % self.NEW,
])
c = ev.cause_for(self.AP, 1004.0, 30.0)
self.assertTrue(c["by_ap"])
self.assertEqual(c["reason"], 2)
self.assertEqual(c["frame"], "deauth")
self.assertAlmostEqual(c["gap_s"], 3.0, places=2)
# The new AP ended nothing; and a cause ages out of the window.
self.assertIsNone(ev.cause_for(self.NEW, 1004.0, 30.0))
self.assertIsNone(ev.cause_for(self.AP, 1100.0, 30.0))
def test_client_roam_is_local_and_instant(self):
ev = self.feed([
"2000.000000: wlo1 (phy #0): deauth %s -> %s reason 1: Unspecified"
% (self.ME, self.AP),
"2000.004000: wlo1 (phy #0): auth %s -> %s status: 0: Successful"
% (self.NEW, self.ME),
])
c = ev.cause_for(self.AP, 2001.0, 30.0)
self.assertFalse(c["by_ap"])
self.assertLess(c["gap_s"], LinkWatch.ROAM_FOLLOW_S)
def test_ap_disassoc_counts_and_case_does_not_matter(self):
ev = self.feed([
"3000.5: wlo1 (phy #0): disassoc %s -> %s reason 4: Disassociated "
"due to inactivity" % (self.AP.upper(), self.ME.upper()),
])
c = ev.cause_for(self.AP, 3001.0, 10.0)
self.assertEqual((c["by_ap"], c["reason"], c["frame"], c["gap_s"]),
(True, 4, "disassoc", None))
def test_forged_and_junk_lines_are_ignored(self):
ev = self.feed([
"4000.0: wlo1 (phy #0): unprotected deauth %s -> %s reason 7: x"
% (self.AP, self.ME),
"4000.1: wlo1 (phy #0): deauth %s -> %s reason 99999999: x"
% (self.AP, self.ME),
"4000.2: wlo1 (phy #0): deauth not-a-mac -> %s reason 2: x" % self.ME,
"garbage", "", "x" * 5000,
])
self.assertIsNone(ev.cause_for(self.AP, 4001.0, 1e9))
def test_untimestamped_lines_take_the_clock_given(self):
ev = NlEvents()
ev.consume("wlo1: deauth %s -> %s reason 3: Leaving" % (self.ME, self.AP),
now=5000.0)
ev.consume("wlo1: connected to %s" % self.NEW, now=5002.5)
c = ev.cause_for(self.AP, 5003.0, 10.0)
self.assertEqual((c["by_ap"], c["reason"]), (False, 3))
self.assertAlmostEqual(c["gap_s"], 2.5, places=3)
def test_follow_up_binds_to_the_newest_cause_only(self):
ev = self.feed([
"6000.0: wlo1 (phy #0): deauth %s -> %s reason 2: x" % (self.AP, self.ME),
"6001.0: wlo1 (phy #0): auth %s -> %s status: 0: Successful"
% (self.NEW, self.ME),
"6002.0: wlo1 (phy #0): assoc %s -> %s status: 0: Successful"
% (self.NEW, self.ME),
])
self.assertAlmostEqual(ev.cause_for(self.AP, 6003.0, 10.0)["gap_s"], 1.0)
def test_reason_words_depend_on_who_sent_it(self):
self.assertEqual(reason_text(4, True), "reason 4: inactivity")
self.assertEqual(reason_text(4, False), "reason 4: beacon loss")
self.assertEqual(reason_text(3, True), "reason 3: the AP is leaving")
self.assertEqual(reason_text(8, True), "reason 8: the AP is leaving the BSS")
self.assertEqual(reason_text(8, False), "reason 8: leaving the BSS")
self.assertIn("previous authentication", reason_text(2, True))
self.assertEqual(reason_text(250, True), "reason 250")
class NlEventRecording(unittest.TestCase):
"""A real kick, recorded with `iw event -t` on an Intel BE201 while the
gateway ran `iwpriv ra1 set DisConnectSta=<this laptop>`: the AP's
deauth, a scan, and the re-association on another radio 2.9 s later.
Addresses are substituted; timings and formats are as recorded."""
OLD = "02:11:22:33:44:01"
NEW = "02:11:22:33:44:05"
def setUp(self):
path = Path(__file__).parent / "fixtures" / "iw-event.txt"
self.lines = path.read_text().splitlines()
self.events = NlEvents()
for line in self.lines:
self.events.consume(line)
def test_recording_parses_as_an_ap_kick(self):
c = self.events.cause_for(self.OLD, 1788278083.0, 30.0)
self.assertEqual((c["by_ap"], c["reason"], c["frame"]),
(True, 8, "deauth"))
self.assertAlmostEqual(c["gap_s"], 2.93, places=1)
# The radio we landed on ended nothing.
self.assertIsNone(self.events.cause_for(self.NEW, 1788278083.0, 30.0))
def test_link_watch_logs_it_as_one_kick(self):
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
store = Store(Path(d.name) / "t.db")
self.addCleanup(store.close)
watch = LinkWatch(store, self.events)
watch.sample(1788278078.0, {"bssid": self.OLD, "ssid": "x",
"channel": 1, "signal_dbm": -30})
watch.sample(1788278083.0, {"bssid": self.NEW, "ssid": "x",
"channel": 149, "signal_dbm": -40})
# Read the log on the recording's own clock. store.events() windows
# on wall-clock now, so anchoring it anywhere else makes this test
# pass until the fixture is a week old and fail every day after.
evs = store.events(now=1788278083.0)
self.assertEqual([e["kind"] for e in evs], ["kick"])
self.assertEqual(
evs[0]["detail"],
"Kicked by AP 02:11:22:33:44:01 (reason 8: the AP is leaving the "
"BSS), rejoined via 02:11:22:33:44:05 after 3 s, "
"channel 1 \u2192 149, -30 \u2192 -40 dBm")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,167 @@
"""Tests for net.py — iw parsing, the trace verdict, tethering, the connection-name cache.
Run: python3 -m unittest discover -s test
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd import net # noqa: E402
from nexthopd.net import nm_metered, tether_from_gateway # noqa: E402
from support import FIXTURES # noqa: E402
class IwParsing(unittest.TestCase):
def test_link_fixture(self):
raw = (FIXTURES / "iw-link.txt").read_text()
original = net._run
net._run = lambda cmd, timeout=2.0: raw
try:
info = net.wifi_link("wlo1")
finally:
net._run = original
self.assertEqual(info["ssid"], "Excitel")
self.assertEqual(info["freq_mhz"], 5180) # float in fixture, int out
self.assertEqual(info["signal_dbm"], -64)
self.assertEqual(info["band"], "5 GHz")
self.assertEqual(info["channel"], 36)
self.assertEqual(info["standard"], "802.11ax")
self.assertEqual(info["width_mhz"], 40)
def test_channel_map(self):
self.assertEqual(net._freq_to_channel(2412), 1)
self.assertEqual(net._freq_to_channel(2484), 14)
self.assertEqual(net._freq_to_channel(5180), 36)
self.assertEqual(net._freq_to_channel(5955), 1)
class TraceParsing(unittest.TestCase):
"""The cdn-cgi/trace response yields one validated address or nothing."""
def test_recorded_response(self):
# Recorded from a real fetch of speed.cloudflare.com/cdn-cgi/trace
# (address substituted): sixteen key=value lines, ip= among them.
text = (FIXTURES / "cf-trace.txt").read_text()
# The recorded fixture carries loc=XX (Cloudflare's "unknown"), so
# no country survives; its colo passes through validated.
self.assertEqual(net.parse_trace(text),
{"ip": "198.51.100.7", "family": "v4",
"edge": "XXX"})
def test_country_and_edge_come_free_with_the_address(self):
# Both are already in the response the reachability check fetches.
self.assertEqual(
net.parse_trace("ip=1.2.3.4\nloc=IN\ncolo=DEL\n"),
{"ip": "1.2.3.4", "family": "v4",
"country": "IN", "edge": "DEL"})
def test_unknown_country_is_withheld_not_shown(self):
# Cloudflare answers XX when it does not know. Showing a country
# called XX would be inventing one.
got = net.parse_trace("ip=1.2.3.4\nloc=XX\ncolo=DEL\n")
self.assertNotIn("country", got)
self.assertEqual(got["edge"], "DEL")
def test_only_a_country_shaped_country_gets_out(self):
# Nothing free-form from the wire reaches the shell, same rule as
# the address itself.
for bad in ("in", "IND", "I", "I1", "<b>", "\u00cd\u00d1"):
got = net.parse_trace("ip=1.2.3.4\nloc=%s\n" % bad)
self.assertNotIn("country", got, bad)
for bad in ("del", "D", "TOOLONG", "D3L", "<i>"):
got = net.parse_trace("ip=1.2.3.4\ncolo=%s\n" % bad)
self.assertNotIn("edge", got, bad)
def test_country_and_edge_never_stand_in_for_an_address(self):
# The address is the point; decoration alone is not a result.
self.assertIsNone(net.parse_trace("loc=IN\ncolo=DEL\n"))
self.assertIsNone(net.parse_trace("ip=nope\nloc=IN\ncolo=DEL\n"))
def test_v6_is_labelled(self):
self.assertEqual(net.parse_trace("h=x\nip=2001:db8::7\nts=1\n"),
{"ip": "2001:db8::7", "family": "v6"})
def test_only_a_real_address_gets_out(self):
# Whatever else the response holds must never reach the shell.
self.assertIsNone(net.parse_trace("ip=<b>not-an-ip</b>\n"))
self.assertIsNone(net.parse_trace("ip=1.2.3.4.5\n"))
self.assertIsNone(net.parse_trace("h=x\nts=1\n"))
self.assertIsNone(net.parse_trace(""))
def test_input_is_bounded_before_parsing(self):
# An ip= line beyond the size cap is as good as absent.
self.assertIsNone(net.parse_trace("x=" + "a" * 5000 + "\nip=1.2.3.4\n"))
self.assertIsNone(net.parse_trace("k=v\n" * 100 + "ip=1.2.3.4\n"))
class TetherDetection(unittest.TestCase):
"""A phone sharing its data, from the one signal that is reliable."""
def test_the_documented_ranges(self):
self.assertEqual(tether_from_gateway("172.20.10.1"),
{"kind": "ios", "label": "iPhone"})
self.assertEqual(tether_from_gateway("192.168.43.1")["kind"], "android")
self.assertEqual(tether_from_gateway("192.168.42.129")["kind"],
"android")
self.assertEqual(tether_from_gateway("192.168.137.1")["kind"],
"windows")
def test_ordinary_gateways_are_not_phones(self):
# Including the hotel gateway that started this: 172.20.0.1 is close
# to the iOS range and outside it, so the /28 matters.
for gw in ("192.168.1.1", "172.20.0.1", "10.0.0.1", "172.20.11.1"):
self.assertIsNone(tether_from_gateway(gw), gw)
def test_nothing_and_nonsense_are_not_phones(self):
for gw in ("", None, "not-an-ip", "999.1.1.1"):
self.assertIsNone(tether_from_gateway(gw))
def test_ipv6_gateway_does_not_raise(self):
self.assertIsNone(tether_from_gateway("fe80::1"))
def test_a_guess_from_networkmanager_is_not_evidence(self):
# A live iPhone hotspot reports "no (guessed)", so only an explicit
# answer may count — and a guessed YES must not either.
import nexthopd.net as netmod
original = netmod._run
try:
for raw, expected in (
("GENERAL.METERED:yes", True),
("GENERAL.METERED:yes (guessed)", False),
("GENERAL.METERED:no", False),
("GENERAL.METERED:no (guessed)", False),
("", False),
(None, False),
):
netmod._run = lambda *a, **k: raw
self.assertEqual(nm_metered("wlan0"), expected, raw)
finally:
netmod._run = original
class ConnectionNameCache(unittest.TestCase):
def test_nmcli_is_asked_on_a_new_key_or_after_the_ttl_only(self):
calls = []
orig = net.connection_name
net.connection_name = lambda iface: calls.append(iface) or "Home"
net._name_cache.clear()
self.addCleanup(setattr, net, "connection_name", orig)
self.addCleanup(net._name_cache.clear)
key = ("wlo1", "192.168.1.1", "aa:bb")
self.assertEqual(net.connection_name_cached("wlo1", key, now=100), "Home")
self.assertEqual(net.connection_name_cached("wlo1", key, now=130), "Home")
self.assertEqual(len(calls), 1)
# A new gateway or BSSID is a new network: ask again.
net.connection_name_cached("wlo1", ("wlo1", "10.0.0.1", "aa:bb"), now=131)
self.assertEqual(len(calls), 2)
# ...and so is a rename the user made, eventually.
net.connection_name_cached("wlo1", ("wlo1", "10.0.0.1", "aa:bb"),
now=131 + net.NAME_CACHE_TTL_S + 1)
self.assertEqual(len(calls), 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,39 @@
"""The sparkline geometry, checked in a real JS engine.
`pathspark.js` is plain JavaScript beside the QML, which puts it outside
everything else in the battery: qmllint does not evaluate it and the
Python suite cannot import it. Two geometry defects shipped from it in a
single day — the ring on the wrong slot, then the ring drawn half outside
the canvas — and neither could have failed a test that existed.
`draw()` takes a context and a size and calls nothing else, so a fake 2d
context recording every mark is enough. The harness is
`test/pathspark_bounds.js`; this runs it. Skipped where node is absent
(the runner-like PATH), so it is CI that enforces it.
"""
import shutil
import subprocess
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from support import REPO # noqa: E402
HARNESS = REPO / "test" / "pathspark_bounds.js"
class SparklineGeometry(unittest.TestCase):
def test_nothing_is_drawn_outside_the_canvas(self):
node = shutil.which("node")
if not node:
self.skipTest("node not on PATH")
r = subprocess.run([node, str(HARNESS)], capture_output=True,
text=True, timeout=60)
self.assertEqual(r.returncode, 0,
f"{r.stdout}\n{r.stderr}")
self.assertIn("all inside the canvas", r.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,441 @@
"""Tests for probes.py — ping parsing, series statistics, load tagging, the TCP probe.
Run: python3 -m unittest discover -s test
"""
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd import score # noqa: E402
from nexthopd.probes import ( # noqa: E402
Series,
PingProbe,
TcpProbe,
RE_REPLY,
RE_PENDING,
RE_UNREACH)
from support import FIXTURES # noqa: E402
class PingParsing(unittest.TestCase):
def test_reply_lines(self):
hits = []
for line in (FIXTURES / "ping-replies.txt").read_text().splitlines():
m = RE_REPLY.match(line)
if m:
hits.append((float(m.group(1)), int(m.group(2)), float(m.group(3))))
self.assertEqual(len(hits), 5)
self.assertEqual(hits[0], (1787562260.703963, 1, 9.13))
self.assertEqual(hits[2][2], 11.3)
def test_loss_lines(self):
pending, unreach = 0, 0
for line in (FIXTURES / "ping-losses.txt").read_text().splitlines():
if RE_PENDING.match(line):
pending += 1
elif RE_UNREACH.match(line):
unreach += 1
self.assertEqual(pending, 6)
self.assertEqual(unreach, 1)
def test_probe_consume_counts_loss_once(self):
"""A seq reported pending, then unreachable, is one loss — not two."""
s = Series()
p = PingProbe("192.0.2.1", s, 500)
p._consume("[100.0] no answer yet for icmp_seq=1\n")
p._consume("[100.5] no answer yet for icmp_seq=1\n")
p._consume("[101.0] From 10.0.0.1 icmp_seq=1 Destination Host Unreachable\n")
stats = Series.stats(s.all())
self.assertEqual(stats["count"], 1)
self.assertEqual(stats["loss"], 1.0)
def test_a_loss_charged_at_expiry_is_not_charged_again(self):
"""The real recording, replayed whole.
seq 1 goes unanswered, the grace period gives up on it, and the
gateway's Destination Host Unreachable for that same seq arrives
0.35 s after that. One packet left, so one loss is recorded. Before
this, the series held two — and overcharging is the direction that
matters, because undercharging a loss is the safe error and this was
the other one.
"""
s = Series()
p = PingProbe("192.0.2.1", s, 500)
for line in (FIXTURES / "ping-losses.txt").read_text().splitlines():
p._consume(line + "\n")
rows = s.all()
self.assertEqual(len(rows), 1)
self.assertIsNone(rows[0][1])
def test_a_reply_after_the_grace_period_adds_nothing(self):
"""A packet already called lost cannot be un-lost by a late reply.
The window it belonged to has been read and scored. Recording the RTT
now would put two samples on the wire's one packet, and flatter the
sample count of every window that reads it.
"""
s = Series()
p = PingProbe("192.0.2.1", s, 500)
p._consume("[100.0] no answer yet for icmp_seq=4\n")
p._consume("[110.0] no answer yet for icmp_seq=9\n") # expires seq 4
p._consume("[110.5] 64 bytes from 1.1.1.1: icmp_seq=4 ttl=60 time=9.0 ms\n")
rows = s.all()
self.assertEqual(len(rows), 1)
self.assertIsNone(rows[0][1])
def test_a_repeated_pending_line_cannot_recharge_a_lost_seq(self):
"""`ping -O` repeats "no answer yet" for the same seq — the recording
does it for seq 5 — so a charged seq must not go back on the list."""
s = Series()
p = PingProbe("192.0.2.1", s, 500)
p._consume("[100.0] no answer yet for icmp_seq=4\n")
p._consume("[110.0] no answer yet for icmp_seq=4\n") # charges it
p._consume("[110.5] no answer yet for icmp_seq=4\n") # must not re-arm
p._consume("[113.0] no answer yet for icmp_seq=4\n") # would re-charge
self.assertEqual(len(s.all()), 1)
def test_a_new_ping_process_can_lose_seq_1_again(self):
"""`ping` numbers from 1 on every respawn.
Remembering a charged seq past the process that produced it would
suppress a genuine loss on the next one, which is the same defect
facing the other way.
"""
s = Series()
p = PingProbe("192.0.2.1", s, 500)
p._consume("[100.0] no answer yet for icmp_seq=1\n")
p._consume("[110.0] no answer yet for icmp_seq=9\n") # charges seq 1
self.assertEqual(len(s.all()), 1)
p._reset_tracking() # ping respawned
p._consume("[200.0] no answer yet for icmp_seq=1\n")
p._consume("[210.0] no answer yet for icmp_seq=2\n") # charges it again
self.assertEqual(len(s.all()), 2)
def test_the_charged_map_does_not_grow_without_bound(self):
"""It is drained by the same clock that fills it."""
s = Series()
p = PingProbe("192.0.2.1", s, 500)
for seq in range(1, 40):
p._consume("[%d.0] no answer yet for icmp_seq=%d\n" % (100 + seq, seq))
self.assertLess(len(p._charged), 5)
self.assertLess(len(p._pending), 5)
def test_probe_expires_silent_losses(self):
"""A pending seq that never resolves is counted after the grace period."""
s = Series()
p = PingProbe("192.0.2.1", s, 500)
p._consume("[100.0] no answer yet for icmp_seq=7\n")
# A reply for a later seq far past the grace window flushes it.
p._consume("[200.0] 64 bytes from 1.1.1.1: icmp_seq=9 ttl=60 time=5.0 ms\n")
stats = Series.stats(s.all())
self.assertEqual(stats["count"], 2)
self.assertEqual(stats["loss"], 0.5)
class SynRetransmits(unittest.TestCase):
"""A handshake rescued by a retransmitted SYN is loss, not latency.
Found 2026-09-10 from the HopSense side and confirmed here in this
machine's own history: an internet-leg p95 sitting at 1032-1041 ms,
constant to within 1% across a twenty-minute episode, while p50 was
5.5 ms and `lag_icmp` never left 12-22 ms. Congestion does not produce
the same number twenty times; a timer does. Folded through
`lag = p75 + 1.5 * jitter` those samples read as 473 ms of lag and took
Responsiveness to 26 on a link ICMP called healthy.
The threshold is RELATIVE, and that is the whole design. `connect()`
returns one RTT after the SYN that survives, so a rescued handshake lands
at one RTO PLUS the path's own round trip — 1008 ms on an 8 ms path,
1150 ms on a 150 ms one. A fixed cutoff would label a congestion spike on
an ordinary intercontinental path as a lost packet, which is a different
fault with a different owner.
"""
def probe(self, baseline_ms=None, samples=None):
p = TcpProbe("192.0.2.1", Series(), 1.0)
if baseline_ms is not None:
n = p.RETRANSMIT_MIN_SAMPLES if samples is None else samples
for i in range(n):
p._recent.append((1000.0 + i, baseline_ms))
return p
def test_a_second_above_a_fast_path_is_a_retransmit(self):
p = self.probe(baseline_ms=8.5)
self.assertEqual(p._classify(1100.0, 1045.0), "retransmit")
self.assertEqual(p._classify(1100.0, 1004.0), "retransmit")
def test_an_ordinary_round_trip_is_not(self):
p = self.probe(baseline_ms=8.5)
for rtt in (8.0, 45.0, 300.0, 899.0):
self.assertEqual(p._classify(1100.0, rtt), "reply", rtt)
def test_a_congestion_spike_on_a_distant_path_stays_a_measurement(self):
"""The case a fixed cutoff gets wrong.
On a 150 ms path a real retransmit lands at ~1150 ms, so a 1045 ms
sample is 895 ms of something that is not a timer. 150-250 ms is
ordinary for a subscriber reaching another continent, so this band is
normal traffic rather than an edge case.
"""
p = self.probe(baseline_ms=150.0)
self.assertEqual(p._classify(1100.0, 1045.0), "reply")
self.assertEqual(p._classify(1100.0, 1150.0), "retransmit")
def test_a_satellite_link_keeps_its_latency(self):
p = self.probe(baseline_ms=600.0)
self.assertEqual(p._classify(1100.0, 1045.0), "reply")
self.assertEqual(p._classify(1100.0, 1600.0), "retransmit")
def test_before_a_baseline_it_is_neither(self):
# Inventing a loss and publishing a suspect latency are both claims.
p = self.probe(baseline_ms=8.5,
samples=TcpProbe.RETRANSMIT_MIN_SAMPLES - 1)
self.assertEqual(p._classify(1100.0, 1045.0), "unknown")
self.assertEqual(p._classify(1100.0, 8.0), "reply")
def test_a_slow_path_bootstraps_instead_of_going_silent(self):
"""The hazard in the third verdict, named.
On a link whose real round trip is past the floor, every sample lands
unclassified. A baseline deque that only accepted classified samples
would never reach its minimum, so the instrument would stay
unclassified for ever — nothing recorded, count never growing,
`penalty()` returning None, and the bench able neither to seat it nor
to call it dead. Silent and unrankable, invisible because it is not
failing but merely absent.
"""
p = TcpProbe("192.0.2.1", Series(), 1.0)
verdicts = []
for i in range(20):
v = p._classify(1000.0 + i, 1200.0)
verdicts.append(v)
if v in ("reply", "unknown"):
p._recent.append((1000.0 + i, 1200.0))
self.assertEqual(set(verdicts[:p.RETRANSMIT_MIN_SAMPLES]), {"unknown"})
self.assertEqual(set(verdicts[p.RETRANSMIT_MIN_SAMPLES:]), {"reply"})
self.assertAlmostEqual(p._baseline_ms(1020.0), 1200.0)
def test_retransmits_do_not_raise_the_bar_that_catches_them(self):
# Feeding them back would ratchet the threshold up on the
# instrument's own retransmits and the rule would stop firing.
p = TcpProbe("192.0.2.1", Series(), 1.0)
for i in range(10):
p._recent.append((1000.0 + i, 8.5))
for i, rtt in enumerate((1045.0, 8.6, 1044.0, 8.4, 1046.0)):
v = p._classify(1010.0 + i, rtt)
if v == "reply":
p._recent.append((1010.0 + i, rtt))
self.assertAlmostEqual(p._baseline_ms(1015.0), 8.5, places=1)
self.assertEqual(p._classify(1015.0, 1045.0), "retransmit")
def test_the_baseline_window_is_a_duration_not_a_count(self):
# An instrument benched to a slower cadence would otherwise have
# "recent" silently mean a longer stretch of wall clock.
p = TcpProbe("192.0.2.1", Series(), 1.0)
for i in range(20):
p._recent.append((1000.0 + i, 8.5))
self.assertIsNotNone(p._baseline_ms(1020.0))
self.assertIsNone(p._baseline_ms(1020.0 + TcpProbe.RETRANSMIT_WINDOW_S))
def test_the_window_matches_what_the_bench_ranks_on(self):
""""Recent" means one thing across the daemon.
These are aliases of the same constants now, so this passes by
construction — and it is kept for exactly that reason. What it catches
is someone replacing an alias with a literal, which is the drift it was
written against in the first place.
"""
from nexthopd.instruments import Bench
self.assertEqual(TcpProbe.RETRANSMIT_WINDOW_S, Bench.WINDOW_S)
self.assertEqual(TcpProbe.RETRANSMIT_MIN_SAMPLES, Bench.MIN_SAMPLES)
def test_an_even_count_takes_the_mean_of_the_middle_two(self):
"""Pinned because a port has to choose, and the choice is invisible.
Rust has no `statistics.median`, so HopSense had to pick a convention
for an even sample count and matched this one. Left unpinned, the two
runtimes would differ by one sample's worth of RTT in a baseline
nobody ever looks at, for ever.
"""
p = TcpProbe("192.0.2.1", Series(), 1.0)
for i, rtt in enumerate((10.0, 20.0, 30.0, 40.0)):
p._recent.append((1000.0 + i, rtt))
p.RETRANSMIT_MIN_SAMPLES = 4
self.assertAlmostEqual(p._baseline_ms(1010.0), 25.0)
def test_the_deque_is_bounded_however_long_the_probe_runs(self):
# The window bounds what is READ; the deque bounds what is HELD, and a
# probe outlives many windows.
p = TcpProbe("192.0.2.1", Series(), 1.0)
for i in range(3072):
p._recent.append((1000.0 + i, 8.5))
self.assertLessEqual(len(p._recent), 1024)
def test_the_floor_is_consulted_before_the_baseline_ever_is(self):
# Below the floor nothing is reclassified, baseline or no baseline.
p = TcpProbe("192.0.2.1", Series(), 1.0)
self.assertEqual(p._classify(1000.0, 899.9), "reply")
self.assertEqual(p._classify(1000.0, 900.0), "unknown")
def test_a_baseline_needs_the_full_count_and_not_one_fewer(self):
p = TcpProbe("192.0.2.1", Series(), 1.0)
for i in range(TcpProbe.RETRANSMIT_MIN_SAMPLES - 1):
p._recent.append((1000.0 + i, 8.5))
self.assertIsNone(p._baseline_ms(1010.0))
p._recent.append((1010.0, 8.5))
self.assertIsNotNone(p._baseline_ms(1010.0))
def test_the_two_retransmit_case_was_already_loss(self):
"""Why the old boundary was arbitrary.
Two retransmits wait 1 s + 2 s, which is past CONNECT_TIMEOUT_S, so
that handshake already timed out and was recorded as loss. One
retransmit came back inside the timeout and was recorded as latency.
The same event, accounted two opposite ways, and the line between them
was wherever the timeout happened to fall.
"""
self.assertLess(TcpProbe.CONNECT_TIMEOUT_S * 1000.0, 3000.0)
self.assertLess(TcpProbe.RETRANSMIT_MARGIN_MS,
TcpProbe.CONNECT_TIMEOUT_S * 1000.0)
class Stats(unittest.TestCase):
def test_empty_and_all_lost(self):
self.assertEqual(Series.stats([])["count"], 0)
s = Series.stats([(0, None), (1, None)])
self.assertEqual(s["loss"], 1.0)
self.assertIsNone(s["p50"])
def test_jitter_is_ipdv_not_stdev(self):
# 10/40 alternation: IPDV is 30, stdev would be ~15.
samples = [(i, 10.0 if i % 2 == 0 else 40.0) for i in range(10)]
self.assertEqual(Series.stats(samples)["jitter"], 30.0)
def test_window_eviction(self):
s = Series(window_s=10)
now = time.time()
s.add(now - 20, 5.0)
s.add(now, 6.0)
self.assertEqual(len(s.all()), 1)
class LoadTagging(unittest.TestCase):
"""Idle vs loaded latency, from the same probe stream.
The gap between them is bufferbloat — the failure a plain latency
number misses, where a line answers in 15 ms at rest and 300 ms
whenever anyone uses it.
"""
def test_samples_carry_the_link_state_they_saw(self):
s = Series()
now = time.time()
s.add(now, 12.0) # default: idle
s.add(now + 1, 250.0, True) # under load
idle, loaded = Series.split_by_load(s.all())
self.assertEqual(len(idle), 1)
self.assertEqual(len(loaded), 1)
self.assertEqual(idle[0][1], 12.0)
self.assertEqual(loaded[0][1], 250.0)
def test_two_element_samples_still_read_as_idle(self):
# Anything holding the old sample shape must not raise.
idle, loaded = Series.split_by_load([(0.0, 10.0), (1.0, None)])
self.assertEqual(len(idle), 2)
self.assertEqual(loaded, [])
self.assertEqual(Series.stats([(0.0, 10.0), (1.0, 30.0)])["p50"], 20.0)
def test_bufferbloat_shows_as_inflation_between_the_two(self):
idle = [(float(i), 15.0, False) for i in range(20)]
loaded = [(float(i + 20), 300.0, True) for i in range(20)]
i_lag = score.lag_ms(Series.stats(idle))
l_lag = score.lag_ms(Series.stats(loaded))
self.assertLess(i_lag, 20)
self.assertGreater(l_lag, 250)
self.assertGreater(l_lag / i_lag, 10)
def test_loss_is_still_counted_per_load_state(self):
samples = [(0.0, 10.0, False), (1.0, None, False),
(2.0, 40.0, True), (3.0, None, True), (4.0, None, True)]
idle, loaded = Series.split_by_load(samples)
self.assertAlmostEqual(Series.stats(idle)["loss"], 0.5)
self.assertAlmostEqual(Series.stats(loaded)["loss"], 2 / 3)
def test_inflation_needs_enough_samples_on_both_sides(self):
import os as _os
from nexthopd.daemon import Daemon, MIN_LOAD_SPLIT_SAMPLES
with tempfile.TemporaryDirectory() as d:
_os.environ["XDG_STATE_HOME"] = d
try:
dm = Daemon()
try:
now = time.time()
# Plenty idle, only a couple loaded: no ratio yet.
for i in range(30):
dm.icmp_anchor.add(now - 60 + i, 15.0, False)
for i in range(MIN_LOAD_SPLIT_SAMPLES - 1):
dm.icmp_anchor.add(now - 5 + i * 0.1, 300.0, True)
b = dm.bufferbloat(300.0)
self.assertIsNotNone(b["idle"])
self.assertIsNotNone(b["loaded"])
self.assertIsNone(b["inflation"])
# One more loaded sample and the comparison is allowed.
dm.icmp_anchor.add(now, 300.0, True)
b = dm.bufferbloat(300.0)
self.assertIsNotNone(b["inflation"])
self.assertGreater(b["inflation"], 5)
finally:
dm.store.close()
finally:
del _os.environ["XDG_STATE_HOME"]
def test_probe_tags_from_its_predicate_and_never_raises(self):
from nexthopd.probes import PingProbe
s = Series()
state = {"busy": False}
p = PingProbe("192.0.2.1", s, 500, "t", loaded_fn=lambda: state["busy"])
self.assertFalse(p._loaded())
state["busy"] = True
self.assertTrue(p._loaded())
# A predicate that blows up must not take the probe with it.
broken = PingProbe("192.0.2.1", s, 500, "t",
loaded_fn=lambda: 1 / 0)
self.assertFalse(broken._loaded())
class TcpProbeBehaviour(unittest.TestCase):
"""The TCP-handshake instruments beside ICMP.
ICMP is answered by fast paths in hardware and can be spoofed by
anything on the way; a handshake to port 443 has to reach a listener
that completes it. Since 0.2.0 these are seated instruments in the
bench (instruments.py), not a comparison probe on the side.
"""
def test_tcp_probe_records_a_failure_rather_than_raising(self):
from nexthopd.probes import TcpProbe
s = Series()
# Reserved-for-documentation address; nothing answers.
p = TcpProbe("192.0.2.1", s, 1.0, "t", port=9)
p.CONNECT_TIMEOUT_S = 0.25
p._once()
self.assertEqual(len(s.all()), 1)
self.assertIsNone(s.all()[0][1])
self.assertFalse(p.ever_connected)
def test_tcp_probe_tags_load_like_the_ping_probe(self):
from nexthopd.probes import TcpProbe
s = Series()
p = TcpProbe("192.0.2.1", s, 1.0, "t", loaded_fn=lambda: True, port=9)
p.CONNECT_TIMEOUT_S = 0.25
p._once()
self.assertTrue(s.all()[0][2])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,531 @@
"""Tests for score.py — the anchor tables, the folds, and the wrong-direction sweep.
Run: python3 -m unittest discover -s test
"""
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd import score # noqa: E402
class Scoring(unittest.TestCase):
def test_lag_charges_for_loss(self):
clean = {"count": 100, "loss": 0.0, "p75": 10.0, "jitter": 1.0}
lossy = {"count": 100, "loss": 0.02, "p75": 10.0, "jitter": 1.0}
self.assertGreater(score.lag_ms(lossy), score.lag_ms(clean) + 15)
def test_score_bands(self):
self.assertEqual(score.band(94), "excellent")
self.assertEqual(score.band(85), "good")
self.assertEqual(score.band(74), "okay")
self.assertEqual(score.band(55), "fair")
self.assertEqual(score.band(10), "poor")
self.assertEqual(score.band(None), "unknown")
def test_index_skips_unknown_components(self):
# Weakest-link: 90 owns the number, 100 nudges it up slightly.
self.assertEqual(score.index(90.0, 100.0, None), 91)
self.assertIsNone(score.index(None, None, None))
# A single measurable component is that component.
self.assertEqual(score.index(None, 73.0, None), 73)
def test_index_does_not_let_good_components_mask_a_broken_one(self):
# A line whose calls do not work, with a fast download and no
# outages. The mean called this 78 ("okay"); the bottleneck is 40.
idx = score.index(40.0, 100.0, 95.0)
self.assertLess(idx, 50)
self.assertEqual(score.band(idx), "poor")
def test_index_still_rewards_an_otherwise_excellent_connection(self):
# All three healthy: the number stays where the components are.
self.assertGreaterEqual(score.index(96.0, 100.0, 94.0), 94)
def test_index_ties_do_not_lose_a_component(self):
self.assertEqual(score.index(70.0, 70.0, 70.0), 70)
def test_loss_costs_the_same_as_before_on_an_ordinary_link(self):
# The flat 1000 ms/unit-loss was right for normal round trips, so
# nothing changes for them — only the shape above the RTO floor.
for p75 in (5.0, 15.0, 40.0, 66.0):
self.assertEqual(score.loss_cost_ms(p75), 1000.0)
def test_loss_costs_more_on_a_high_latency_link(self):
# A retransmit on a 600 ms link is not the same 10 ms per percent
# that it is on fibre.
self.assertGreater(score.loss_cost_ms(600.0),
score.loss_cost_ms(15.0) * 5)
fibre = score.lag_ms({"count": 100, "p75": 15.0, "jitter": 3.0,
"loss": 0.01})
sat = score.lag_ms({"count": 100, "p75": 600.0, "jitter": 30.0,
"loss": 0.01})
self.assertAlmostEqual(fibre - 19.5, 10.0, places=1) # 10 ms, as before
self.assertAlmostEqual(sat - 645.0, 90.0, places=1) # 90 ms, scaled
def test_loss_cost_never_drops_below_the_rto_floor(self):
self.assertEqual(score.loss_cost_ms(0.0),
score.LOSS_STALL_FACTOR * score.LOSS_RTO_FLOOR_MS)
def test_reliability_charges_outages_harder_than_self_healed_blips(self):
# The inversion this replaced: three brief disruptions used to cost
# 18 points while a ten-minute outage cost 0.7, so the milder event
# was punished twenty-six times harder.
day = 24 * 3600
outage = score.reliability(600 / day, 0)
blips = score.reliability(0.0, 3, disruption_fraction=45 / day)
self.assertLess(outage, blips,
"ten minutes fully down must cost more than three "
"short self-healed blips")
def test_reliability_scales_with_downtime(self):
day = 24 * 3600
self.assertGreater(score.reliability(600 / day, 0),
score.reliability(3600 / day, 0))
self.assertGreater(score.reliability(3600 / day, 0),
score.reliability(6 * 3600 / day, 0))
def test_a_bad_evening_of_blips_cannot_zero_reliability(self):
# Seventeen disruptions used to land on exactly 0.0.
day = 24 * 3600
rel = score.reliability(0.0, 17, disruption_fraction=17 * 30 / day)
self.assertGreater(rel, 90.0) # not a catastrophe
self.assertLess(rel, 100.0) # but not free either
def test_repeated_blips_still_cost_more_than_one(self):
day = 24 * 3600
one = score.reliability(0.0, 1, disruption_fraction=300 / day)
many = score.reliability(0.0, 10, disruption_fraction=300 / day)
self.assertLess(many, one)
def test_total_downtime_floors_at_zero(self):
self.assertEqual(score.reliability(1.0, 0), 0.0)
def test_uncovered_window_is_not_punished(self):
self.assertEqual(score.reliability(0.0, 5, covered=False), 100.0)
def test_wan_subtraction_monotone(self):
w = score.wan_from(
{"count": 60, "loss": 0.0, "p50": 11.5, "p75": 15.5, "p95": 22.5,
"max": 25.2, "jitter": 4.9, "last": 8.0},
{"count": 60, "loss": 0.0, "p50": 8.6, "p75": 9.8, "p95": 19.8,
"max": 21.2, "jitter": 4.2, "last": 7.8})
self.assertLessEqual(w["p50"], w["p75"])
self.assertLessEqual(w["p75"], w["p95"])
self.assertLessEqual(w["p95"], w["max"])
def test_wan_loss_never_negative(self):
w = score.wan_from({"count": 10, "loss": 0.0, "p50": 5.0},
{"count": 10, "loss": 0.1, "p50": 2.0})
self.assertEqual(w["loss"], 0.0)
def test_speed_prefers_download(self):
# Full download, empty upload should still score well above 50.
self.assertGreater(score.speed(450, 0.1, 450, 50), 70)
def test_speed_absolute_needs_no_config(self):
# The default basis scores without a plan and saturates sensibly.
self.assertIsNotNone(score.speed(230, 100))
self.assertGreater(score.speed(230, 100), 90)
self.assertLess(score.speed(10, 2), 40)
# Past the perception ceiling extra speed barely moves the score.
self.assertLess(score.speed(900, 200) - score.speed(500, 100), 3)
def test_degradation_penalty_only_on_big_drops(self):
# Ordinary shared-line variance is free; a real drop is not.
self.assertEqual(score.degradation_penalty(200, 300), 0.0)
self.assertGreater(score.degradation_penalty(60, 300), 15)
# No baseline, no penalty — cold start stays honest.
self.assertEqual(score.degradation_penalty(60, None), 0.0)
def test_plan_overrides_absolute(self):
# A configured plan switches the basis entirely.
with_plan = score.speed(412, 48, 450, 50)
without = score.speed(412, 48)
self.assertNotEqual(with_plan, without)
class SpeedScoring(unittest.TestCase):
"""Daemon.speed_score against a real store, no probes started."""
def setUp(self):
import os as _os
from nexthopd.daemon import Daemon
self.dir = tempfile.TemporaryDirectory()
_os.environ["XDG_STATE_HOME"] = self.dir.name
self.daemon = Daemon()
self._os = _os
def tearDown(self):
self.daemon.store.close()
del self._os.environ["XDG_STATE_HOME"]
self.dir.cleanup()
def put(self, ago_s, down, up, network):
self.daemon.store.put_test(int(time.time() - ago_s), "content",
"cloudflare", down_mbps=down, up_mbps=up,
ok=True, network=network)
def test_other_networks_checks_do_not_score_here(self):
self.put(600, 300, 100, "OfficeA")
spd, ctx = self.daemon.speed_score(time.time(), "OfficeB")
self.assertIsNone(spd)
self.assertTrue(ctx.get("pending"))
def test_median_shrugs_off_one_bad_check(self):
self.put(7200, 220, 90, "OfficeA")
self.put(3600, 240, 95, "OfficeA")
self.put(600, 18, 5, "OfficeA") # the mid-roam outlier
spd, ctx = self.daemon.speed_score(time.time(), "OfficeA")
self.assertEqual(ctx["last_down"], 220) # median, not the outlier
self.assertGreater(spd, 85)
def test_no_cross_network_penalty(self):
# A fast history elsewhere must not depress a slower network.
for i in range(6):
self.put(3600 * (i + 2), 300, 100, "FastOffice")
self.put(600, 30, 10, "SlowCafe")
spd, ctx = self.daemon.speed_score(time.time(), "SlowCafe")
self.assertIsNone(ctx["baseline_down"])
# Pure absolute curve for 30/10: mid-50s to 60 — no minus-35 cliff.
self.assertGreater(spd, 50)
class UnknownWanLeg(unittest.TestCase):
def test_missing_local_yields_unknown_not_the_whole_round_trip(self):
total = {"count": 500, "p50": 3.5, "p75": 4.0, "p95": 18.0,
"max": 26.0, "loss": 0.0, "jitter": 4.8, "last": 3.5}
gone = {"count": 0, "p50": None, "p75": None, "p95": None,
"max": None, "loss": 1.0, "jitter": None, "last": None}
w = score.wan_from(total, gone)
# Substituting zero used to make the derived leg equal the total, so
# a silent gateway produced a confident healthy internet figure that
# was really the whole round trip wearing the wan leg's label.
for key in ("p50", "p75", "p95", "max"):
self.assertIsNone(w[key], key)
def test_known_local_still_subtracts(self):
total = {"count": 500, "p50": 10.0, "p75": 12.0, "p95": 20.0,
"max": 30.0, "loss": 0.0, "jitter": 1.0, "last": 10.0}
local = {"count": 500, "p50": 2.0, "p75": 2.5, "p95": 5.0,
"max": 8.0, "loss": 0.0, "jitter": 0.4, "last": 2.0}
w = score.wan_from(total, local)
self.assertEqual(w["p50"], 8.0)
self.assertGreaterEqual(w["p95"], w["p50"])
class LagBand(unittest.TestCase):
def test_one_scale_so_the_range_cannot_read_backwards(self):
# The reported case: "best 4 · typical 644 ms · worst 26" — two raw
# round trips either side of a loss-charged composite.
lossy = {"count": 500, "p50": 3.5, "p75": 4.0, "p95": 18.0,
"max": 26.0, "loss": 1.0, "jitter": 4.8}
b = score.lag_band(lossy)
self.assertLessEqual(b["best"], b["typical"])
self.assertLessEqual(b["typical"], b["worst"])
# Loss moves all three together, which is what a range implies.
self.assertGreater(b["best"], 1000)
def test_monotonic_across_loss_levels_and_degenerate_windows(self):
base = {"count": 500, "p50": 5.0, "p75": 5.0, "p95": 5.0,
"max": 5.0, "jitter": 0.0}
for loss in (0.0, 0.01, 0.35, 1.0):
b = score.lag_band(dict(base, loss=loss))
vals = [b["best"], b["typical"], b["worst"]]
self.assertEqual(vals, sorted(vals), loss)
def test_no_samples_reports_nothing(self):
b = score.lag_band({"count": 0})
self.assertEqual(b, {"best": None, "typical": None, "worst": None})
class OutagePresentation(unittest.TestCase):
"""What the panel may say when nothing is replying."""
DEAD = {"count": 60, "p50": None, "p75": None, "p95": None,
"max": None, "loss": 1.0, "jitter": None}
def test_scoring_keeps_its_anchor(self):
# Responsiveness must still land on zero, which is what 1500 is for.
self.assertEqual(score.lag_ms(self.DEAD), 1500.0)
self.assertEqual(score.responsiveness(score.lag_ms(self.DEAD)), 0.0)
def test_display_band_shows_nothing_rather_than_the_anchor(self):
# 1500 is an anchor, not a round trip. The panel printed it three
# times as "best 1500 · typical 1500 ms · worst 1500", which says
# the link is replying slowly when it is not replying.
self.assertEqual(score.lag_band(self.DEAD),
{"best": None, "typical": None, "worst": None})
def test_partial_loss_still_reports_a_band(self):
lossy = {"count": 500, "p50": 5.0, "p75": 6.0, "p95": 20.0,
"max": 30.0, "loss": 0.4, "jitter": 1.0}
b = score.lag_band(lossy)
self.assertIsNotNone(b["typical"])
self.assertLessEqual(b["best"], b["typical"])
self.assertLessEqual(b["typical"], b["worst"])
class DrainAfterLoad(unittest.TestCase):
"""Depth is what everyone reports; duration is what you feel after the
download has finished."""
def test_measures_from_the_last_loaded_sample(self):
sm = [(1, 10, False), (2, 10, False), (3, 60, True), (4, 80, True),
(5, 70, True), (5.4, 40, False), (6.2, 12, False),
(7, 10, False)]
d = score.drain_after_load(sm, 10.0)
# Load ended at t=5; latency was back inside tolerance at t=6.2.
self.assertEqual(d["ms"], 1200.0)
self.assertTrue(d["settled"])
def test_tolerance_not_exactness(self):
# A queue does not empty to the exact millisecond it started from.
sm = [(1, 10, True), (2, 12.4, False)]
self.assertTrue(score.drain_after_load(sm, 10.0)["settled"])
sm = [(1, 10, True), (2, 20.0, False)]
self.assertFalse(score.drain_after_load(sm, 10.0)["settled"])
def test_still_under_load_says_nothing(self):
sm = [(1, 10, False), (2, 80, True)]
self.assertIsNone(score.drain_after_load(sm, 10.0)["ms"])
def test_no_burst_says_nothing(self):
sm = [(1, 10, False), (2, 11, False)]
self.assertIsNone(score.drain_after_load(sm, 10.0)["ms"])
def test_unrecovered_reports_a_floor_not_a_recovery(self):
sm = [(1, 10, True), (2, 90, False), (3, 88, False), (4, 86, False)]
d = score.drain_after_load(sm, 10.0)
self.assertEqual(d["ms"], 3000.0)
self.assertFalse(d["settled"]) # never came back
def test_beyond_the_cap_is_not_a_drain(self):
sm = [(0, 10, True), (score.DRAIN_MAX_S + 5, 10, False)]
self.assertIsNone(score.drain_after_load(sm, 10.0)["ms"])
def test_lost_probes_are_skipped_not_treated_as_recovery(self):
sm = [(1, 90, True), (2, None, False), (3, None, False),
(4, 11, False)]
d = score.drain_after_load(sm, 10.0)
self.assertEqual(d["ms"], 3000.0)
self.assertTrue(d["settled"])
def test_no_baseline_no_claim(self):
sm = [(1, 90, True), (2, 10, False)]
self.assertIsNone(score.drain_after_load(sm, None)["ms"])
self.assertIsNone(score.drain_after_load(sm, 0)["ms"])
class Pressure(unittest.TestCase):
"""The fast channel the index cannot be."""
def test_bands(self):
self.assertEqual(score.pressure(socket_queue_ms=5.1)["state"], "clear")
self.assertEqual(score.pressure(socket_queue_ms=18.4)["state"], "busy")
self.assertEqual(score.pressure(socket_queue_ms=64.0)["state"],
"congested")
def test_real_traffic_beats_an_inference_from_probes(self):
p = score.pressure(socket_queue_ms=3.0, loaded_ms=99.0, idle_ms=10.0)
self.assertEqual(p["source"], "sockets")
self.assertEqual(p["queue_ms"], 3.0)
def test_probes_are_the_fallback(self):
p = score.pressure(loaded_ms=48.0, idle_ms=12.0)
self.assertEqual(p["source"], "probes")
self.assertEqual(p["queue_ms"], 36.0)
def test_a_backwards_difference_is_withheld(self):
# Queueing cannot be negative; that means the split is unreliable,
# not that load made the link quicker. Same rule as the inflation
# plausibility floor.
self.assertIsNone(score.pressure(loaded_ms=10.0, idle_ms=12.0)["state"])
def test_nothing_to_say_is_a_valid_answer(self):
self.assertIsNone(score.pressure()["state"])
self.assertIsNone(score.pressure(loaded_ms=50.0)["state"])
class WrongDirectionSweep(unittest.TestCase):
"""A result wrong in DIRECTION is not a result.
Four instances turned up one at a time this session — the loaded/idle
ratio at 0.87, the 1500 ms scoring anchor printed as a latency, the
internet leg substituting zero for an unknown local leg, and a peak test
graded A+ for measuring lower latency under load than at rest. This
class pins the class rather than the instances, so a fifth cannot be
introduced quietly.
"""
TOTAL = {"count": 500, "p50": 8.1, "p75": 10.4, "p95": 14.4,
"max": 26.0, "loss": 0.0, "jitter": 2.3, "last": 8.0}
def test_a_slower_router_than_internet_withholds_the_isp_leg(self):
# Gateways commonly deprioritise ICMP addressed to themselves, so
# their own replies are slow while everything they forward is fast.
# That is a fact about the control plane, not the ISP's share.
slow_gateway = {"count": 500, "p50": 20.0, "p75": 25.0, "p95": 40.0,
"max": 60.0, "loss": 0.0, "jitter": 3.0, "last": 20.0}
w = score.wan_from(self.TOTAL, slow_gateway)
for key in ("p50", "p75", "p95", "max", "last"):
self.assertIsNone(w[key], key)
def test_a_genuinely_near_zero_isp_leg_is_still_reported(self):
# An anchor a hop past the gateway really can cost almost nothing;
# withholding that would be its own kind of dishonesty.
near = dict(self.TOTAL, p50=8.4, p75=10.6, p95=14.6, max=26.2)
w = score.wan_from(self.TOTAL, near)
self.assertEqual(w["p50"], 0.0)
def test_normal_subtraction_untouched(self):
local = {"count": 500, "p50": 2.1, "p75": 2.5, "p95": 5.0,
"max": 8.0, "loss": 0.0, "jitter": 0.4, "last": 2.0}
w = score.wan_from(self.TOTAL, local)
self.assertEqual(w["p50"], 6.0)
self.assertEqual(w["last"], 6.0)
def test_loss_subtraction_stays_clamped_and_that_is_correct(self):
# Unlike latency, this one is legitimate: loss on the local link
# shows up in both probes, so if the internet probe lost nothing the
# wan leg genuinely lost nothing, however much the router probe lost.
lossy_local = dict(self.TOTAL, loss=0.3)
w = score.wan_from(dict(self.TOTAL, loss=0.0), lossy_local)
self.assertEqual(w["loss"], 0.0)
def test_the_signed_difference_stays_signed(self):
# icmp_delta_ms is MEANT to go both ways: our own measurements show
# ICMP optimistic at the median and pessimistic in the tail, so a
# negative delta is a finding, not an error. Guarding it would
# destroy the comparison it exists for.
self.assertLess(score.lag_ms(dict(self.TOTAL, p75=4.0)),
score.lag_ms(dict(self.TOTAL, p75=40.0)))
class SpeedTrust(unittest.TestCase):
"""Weakest-link means the lowest component is the headline, so the
thinnest input must not hold a veto over it."""
def test_one_sample_is_not_enough(self):
self.assertFalse(score.speed_scored(63.4, 1))
self.assertTrue(score.speed_scored(63.4, 2))
def test_a_contradicting_peak_withdraws_the_figure(self):
# 251 Mbps measured by hand on the same line disproves 63.
self.assertFalse(score.speed_scored(63.4, 3, 251.4))
# Within the same order of magnitude it stands: a saturating test
# reading somewhat higher than an everyday sample is normal.
self.assertTrue(score.speed_scored(200.0, 3, 251.4))
def test_nothing_measured_is_never_scored(self):
self.assertFalse(score.speed_scored(None, 9))
def test_the_index_leaves_an_untrusted_figure_out(self):
# The reported case: a healthy line read POOR because one check
# pinned the headline.
pinned = score.index(97.3, 97.0, 42.4)
honest = score.index(97.3, 97.0, None)
self.assertLess(pinned, 50)
self.assertGreater(honest, 90)
class HeadlineDuringAnOutage(unittest.TestCase):
"""The index must not contradict the verdict printed beside it.
Observed 2026-09-08 on a real 61 s Wi-Fi drop: EXPERIENCE 100 directly
beneath ROUTER UNREACHABLE, because Lag's 30 s window still held
pre-outage replies and 61 s against 24 h rounds Reliability to 100.
"""
def test_a_confirmed_outage_withholds_the_headline(self):
for state in ("local-down", "wan-down"):
self.assertFalse(score.scored_now(state), state)
def test_ordinary_states_keep_it(self):
for state in ("online", "degraded", "captive"):
self.assertTrue(score.scored_now(state), state)
def test_a_quiet_spell_is_not_an_outage(self):
# gateway-quiet and icmp-quiet leave the state calm on purpose:
# traffic still crosses the leg, so the index still means something.
self.assertTrue(score.scored_now("online"))
def test_the_components_that_produced_it_still_stand(self):
# Only the headline is withheld. Reliability really is 100 over the
# window, and its pillar says so in amber with the live downtime.
self.assertEqual(score.index(100.0, 100.0, None), 100)
self.assertFalse(score.scored_now("local-down"))
def test_withholding_is_a_band_of_unknown_not_of_poor(self):
# A withheld index must not colour as a bad one. band(None) is the
# same "unknown" every other absent figure uses.
self.assertEqual(score.band(None), "unknown")
class WanPerPoint(unittest.TestCase):
"""One pair of readings to one ISP-leg figure.
Factored out of `wan_from` so the per-point series in recent.json and the
per-window statistics cannot drift: the rule that matters here is the one
that REFUSES to answer, and a second copy of it is a copy that can forget.
"""
def test_it_subtracts_the_router_share(self):
self.assertEqual(score.wan_point_ms(11.13, 3.92), 7.21)
def test_a_gateway_slower_than_the_internet_says_nothing(self):
# Plenty of routers deprioritise ICMP addressed to themselves, so the
# local leg reads slower than the total that crosses it. The
# subtraction has nothing to say about the line; a clamped zero would
# report a perfect ISP leg from an invalid measurement.
self.assertIsNone(score.wan_point_ms(5.0, 9.0))
def test_inside_the_tolerance_it_still_answers(self):
# A hair over is measurement noise, not an inversion.
self.assertEqual(score.wan_point_ms(5.0, 5.5), 0.0)
self.assertIsNone(
score.wan_point_ms(5.0, 5.0 + score.WAN_INVERSION_TOLERANCE_MS + 0.01))
def test_either_reading_missing_is_unknown_not_zero(self):
self.assertIsNone(score.wan_point_ms(5.0, None))
self.assertIsNone(score.wan_point_ms(None, 2.0))
self.assertIsNone(score.wan_point_ms(None, None))
def test_the_per_point_rule_refuses_exactly_when_wan_from_does(self):
"""The two must never disagree about WHAT THEY REFUSE.
Raised by the HopSense session 2026-09-12: their Go port is pinned to
`wan_from` by golden vectors, and a per-point refusal is a surface
those vectors do not cover. They cannot diverge today because
`wan_from` calls this helper — this pins that they still cannot after
someone inlines it back for speed.
"""
cases = [(10.0, 2.0), (10.0, 10.0), (10.0, 10.5), (10.0, 11.5),
(10.0, 30.0), (0.5, 0.4), (None, 2.0), (10.0, None),
(None, None), (0.0, 0.0)]
for total, local in cases:
point = score.wan_point_ms(total, local)
window = score.wan_from({"p50": total, "count": 9}, {"p50": local})
self.assertEqual(
point is None, window["p50"] is None,
"disagreed on total=%s local=%s: point=%s window=%s"
% (total, local, point, window["p50"]))
def test_wan_from_still_floors_each_statistic_at_the_last(self):
# The helper is unfloored by design; `wan_from` carries the floor
# forward FLOORED, which is what keeps a derived leg reading like a
# distribution. Extracting the arithmetic broke this once.
w = score.wan_from(
{"p50": 10.0, "p75": 12.0, "p95": 13.0, "max": 13.5, "count": 9},
{"p50": 1.0, "p75": 1.0, "p95": 8.0, "max": 12.0})
self.assertLessEqual(w["p50"], w["p75"])
self.assertLessEqual(w["p75"], w["p95"])
self.assertLessEqual(w["p95"], w["max"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,608 @@
"""Tests for speedtest.py — target vetting, rate accounting, pass sizing, and
JSON we did not write.
Run: python3 -m unittest discover -s test
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
class UntrustedTargets(unittest.TestCase):
"""fast.com nominates its own download hosts, so that JSON decides
what this daemon connects to and is hostile input, not a server list.
Literal addresses throughout, so nothing here touches DNS.
"""
def setUp(self):
from nexthopd.speedtest import vet_target
self.vet = vet_target
def test_plaintext_is_refused(self):
self.assertIsNone(self.vet("http://93.184.216.34/download"))
def test_non_http_schemes_are_refused(self):
for url in ("file:///etc/passwd", "ftp://93.184.216.34/x",
"gopher://93.184.216.34/x", "scp://93.184.216.34/x",
"dict://93.184.216.34/x"):
self.assertIsNone(self.vet(url), url)
def test_loopback_is_refused(self):
for url in ("https://127.0.0.1/x", "https://127.1.2.3/x",
"https://[::1]/x"):
self.assertIsNone(self.vet(url), url)
def test_private_ranges_are_refused(self):
for url in ("https://192.168.1.1/x", "https://10.0.0.1/x",
"https://172.16.4.2/x", "https://[fd00::1]/x"):
self.assertIsNone(self.vet(url), url)
def test_cloud_metadata_address_is_refused(self):
# The link-local address every SSRF write-up ends at.
self.assertIsNone(self.vet("https://169.254.169.254/latest/meta-data/"))
def test_ipv4_mapped_private_address_is_refused(self):
# ::ffff:192.168.1.1 is a private address wearing an IPv6 coat.
self.assertIsNone(self.vet("https://[::ffff:192.168.1.1]/x"))
def test_unspecified_and_broadcast_refused(self):
self.assertIsNone(self.vet("https://0.0.0.0/x"))
self.assertIsNone(self.vet("https://255.255.255.255/x"))
def test_garbage_is_refused_without_raising(self):
for url in ("", "not a url", "https://", "https:///x", "https://:443/x"):
self.assertIsNone(self.vet(url), repr(url))
def test_public_https_is_accepted_and_pinned(self):
got = self.vet("https://8.8.8.8/download?size=25000000")
self.assertIsNotNone(got)
url, resolve = got
self.assertEqual(url, "https://8.8.8.8/download?size=25000000")
# The vetted address is pinned, so curl cannot resolve the name
# again and be handed a different one.
self.assertEqual(resolve, "8.8.8.8:443:8.8.8.8")
def test_explicit_port_is_carried_into_the_pin(self):
got = self.vet("https://8.8.8.8:8443/x")
self.assertIsNotNone(got)
self.assertEqual(got[1], "8.8.8.8:8443:8.8.8.8")
def test_a_bad_target_costs_one_candidate_not_the_test(self):
urls = ["http://93.184.216.34/a", "https://169.254.169.254/b",
"https://8.8.8.8/c"]
vetted = [v for v in (self.vet(u) for u in urls) if v]
self.assertEqual(len(vetted), 1)
self.assertEqual(vetted[0][0], "https://8.8.8.8/c")
def test_curl_is_invoked_with_a_scheme_floor(self):
# Belt and braces beside the vetting: curl itself refuses
# anything but TLS, whatever it is handed.
import inspect
from nexthopd import speedtest
src = inspect.getsource(speedtest._curl)
self.assertIn('"--proto", "=https"', src)
class PeakSizing(unittest.TestCase):
"""The sustained pass is sized from the estimate, floored and capped."""
def test_sized_for_ten_seconds_at_measured_rate(self):
from nexthopd import speedtest
# 160 Mbps line over 4 streams: each stream carries 40 Mbps.
n = speedtest._sized_pass(160.0 / speedtest.PEAK_STREAMS,
speedtest.PEAK_DOWN_FLOOR,
speedtest.CLOUDFLARE_DOWN_MAX)
self.assertEqual(n, 50_000_000)
self.assertAlmostEqual(speedtest._pass_seconds(40.0, n), 10.0)
def test_slow_line_stays_small(self):
from nexthopd import speedtest
n = speedtest._sized_pass(10.0, speedtest.PEAK_DOWN_FLOOR,
speedtest.CLOUDFLARE_DOWN_MAX)
self.assertEqual(n, 12_500_000)
def test_caps_bound_both_directions(self):
from nexthopd import speedtest
# __down 403s at 100 MB and above — the per-stream cap must stay under.
self.assertLess(speedtest.CLOUDFLARE_DOWN_MAX, 100_000_000)
self.assertEqual(speedtest._sized_pass(10_000.0, speedtest.PEAK_DOWN_FLOOR,
speedtest.CLOUDFLARE_DOWN_MAX),
speedtest.CLOUDFLARE_DOWN_MAX)
self.assertEqual(speedtest._sized_pass(0.1, speedtest.PEAK_UP_FLOOR,
speedtest.PEAK_UP_CAP),
speedtest.PEAK_UP_FLOOR)
class RemoteJsonShapes(unittest.TestCase):
"""The peak engines parse JSON we did not write. A wrong shape is a
failed engine, never an exception escaping the worker thread."""
def test_fast_com_wrong_shapes_fail_closed(self):
from nexthopd import speedtest
class R:
returncode = 0
orig = speedtest._curl
self.addCleanup(setattr, speedtest, "_curl", orig)
for body in ('[1, 2]', '{"targets": [1, 2]}', '{"targets": "x"}',
'{"targets": [{"url": 5}]}', 'null'):
r = R()
r.stdout = body
speedtest._curl = lambda args, timeout, r=r: r
self.assertIsNone(speedtest._peak_fast(), body)
def test_ookla_wrong_shapes_fail_closed(self):
from nexthopd import speedtest
class R:
returncode = 0
self.addCleanup(setattr, speedtest.subprocess, "run",
speedtest.subprocess.run)
self.addCleanup(setattr, speedtest.shutil, "which",
speedtest.shutil.which)
speedtest.shutil.which = lambda name: "/usr/bin/true"
for body in ('{"download": "x"}', '[1]', 'null',
'{"download": {"bandwidth": "fast"}, "upload": {"bandwidth": 1},'
' "ping": {"latency": 1}, "server": {}}'):
r = R()
r.stdout = body
speedtest.subprocess.run = lambda *a, r=r, **k: r
self.assertIsNone(speedtest._peak_ookla(), body)
class PayloadTimedRate(unittest.TestCase):
"""What the rate is divided by.
curl's own speed_download divides the bytes by the WHOLE request, setup
included. That biases every reading low by a share that grows with the
line rate, because the setup cost is fixed while the useful part of the
transfer keeps getting shorter — so the faster the connection, the worse
it reads. A user with an 893 Mbps line saw ~220 (issue #2), and the check
could not report above ~480 on a scale whose top anchors are 500 and 750.
The property worth pinning is not any one number: it is that the error no
longer depends on the line.
"""
def rate(self, size, setup_s, payload_s):
from nexthopd import speedtest
return speedtest._rate_over_payload(size, setup_s + payload_s, setup_s)
def test_setup_time_is_not_counted_as_transfer(self):
# 1.5 MB (12 Mbit) carried in 100 ms, after 90 ms of connect and TLS.
self.assertAlmostEqual(self.rate(1_500_000, 0.09, 0.10), 120.0, places=6)
def test_the_error_no_longer_grows_with_the_line(self):
# The check's own shape: 3 MB per stream over four streams, one fixed
# 90 ms of setup, three very different lines. Timed over the payload
# each comes back as itself; timed over the whole request the gigabit
# one would read barely half its rate.
for line_mbps in (50.0, 400.0, 1000.0):
per_stream = line_mbps / 4
payload = (3_000_000 * 8 / 1e6) / per_stream
got = self.rate(3_000_000, 0.09, payload) * 4
self.assertAlmostEqual(got / line_mbps, 1.0, places=6,
msg="%s Mbps read as %s" % (line_mbps, got))
def test_the_shipping_sizing_can_time_a_gigabit_line(self):
# 3 MB over four streams is 96 ms of payload per stream at 1 Gbps —
# above the floor, so the figure stands. It stops being timeable a
# little under 2 Gbps, and is then withheld rather than guessed at.
from nexthopd import speedtest
per_stream_at_1g = (3_000_000 * 8 / 1e6) / (1000.0 / 4)
self.assertGreater(per_stream_at_1g, speedtest.MIN_TIMED_WINDOW_S)
per_stream_at_2g = (3_000_000 * 8 / 1e6) / (2000.0 / 4)
self.assertLess(per_stream_at_2g, speedtest.MIN_TIMED_WINDOW_S)
def test_a_window_too_short_to_time_is_withheld(self):
# 3 MB at 2 Gbps is 12 ms of payload. Dividing by a window that small
# publishes the timing error, not the line — and in the flattering
# direction. Withhold instead, the way every other figure here does.
self.assertIsNone(self.rate(3_000_000, 0.09, 0.012))
def test_the_floor_is_the_window_not_the_total(self):
# A long setup does not make a short payload measurable.
self.assertIsNone(self.rate(3_000_000, 5.0, 0.01))
self.assertIsNotNone(self.rate(3_000_000, 0.0, 0.20))
def test_nothing_transferred_is_not_a_rate(self):
self.assertIsNone(self.rate(0, 0.09, 1.0))
def test_an_impossible_window_is_withheld_not_negated(self):
from nexthopd import speedtest
self.assertIsNone(speedtest._rate_over_payload(1_000_000, 0.05, 0.20))
class RateAccountingContract(unittest.TestCase):
"""The curl invocations have to keep asking for the fields we divide by."""
def source(self, fn):
import inspect
return inspect.getsource(fn)
def test_downloads_are_timed_from_the_first_byte(self):
from nexthopd import speedtest
for fn in (speedtest._curl_timed_download, speedtest._parallel_download):
src = self.source(fn)
self.assertIn("%{time_starttransfer}", src)
self.assertNotIn("%{speed_download}", src)
def test_uploads_are_timed_from_the_handshake(self):
# Not time_starttransfer: on a POST that is the first byte of the
# RESPONSE, which arrives after the body has already gone. Measured
# against a local server: starttransfer landed part way through a
# 2 MB body, so it cannot mark where the payload began.
from nexthopd import speedtest
src = self.source(speedtest._curl_timed_upload)
self.assertIn("%{time_appconnect}", src)
self.assertNotIn("%{speed_upload}", src)
class ParallelStreamAccounting(unittest.TestCase):
"""Summing streams, when some of them cannot be timed."""
class FakeProc:
def __init__(self, out, rc=0):
self._out, self.returncode = out, rc
def communicate(self, timeout=None):
return self._out, None
def kill(self):
pass
def wait(self):
pass
def parallel(self, outputs):
from nexthopd import speedtest
queue = list(outputs)
real_popen = speedtest.subprocess.Popen
speedtest.subprocess.Popen = lambda *a, **k: self.FakeProc(queue.pop(0))
try:
return speedtest._parallel_download("https://x/y", len(outputs), 5)
finally:
speedtest.subprocess.Popen = real_popen
def test_overlapping_streams_are_the_bytes_over_the_time_they_took(self):
# Two streams, each 3 MB carried in the same 200 ms window: 6 MB in
# 0.2 s is 240 Mbps. Note this is NOT 120 + 120 by coincidence of
# them overlapping exactly — see the staggered case below.
out = "3000000 0.29 0.09"
mbps, size = self.parallel([out, out])
self.assertAlmostEqual(mbps, 240.0, delta=1.0)
self.assertEqual(size, 6_000_000)
def test_a_stream_that_outlives_the_others_does_not_double_the_line(self):
"""The defect this replaced.
Streams finish tens to hundreds of milliseconds apart, and one left
running alone measures the whole line. Summing that with what its
siblings measured while sharing counts the same wire twice. Here: one
stream carries 3 MB in 0.2 s, a second carries 3 MB in a 0.2 s window
that starts after the first has finished. Six MB crossed the wire in
0.4 s, which is 120 Mbps. Summing would have said 240.
"""
mbps, size = self.parallel(["3000000 0.29 0.09", "3000000 0.49 0.29"])
self.assertAlmostEqual(mbps, 120.0, delta=2.0)
self.assertEqual(size, 6_000_000)
def test_a_gap_between_streams_is_not_billed_as_throughput(self):
# Union, not first-start-to-last-finish: nothing crossed the wire in
# the idle second between them, and it must not be charged as if the
# line were slow.
mbps, _ = self.parallel(["3000000 0.29 0.09", "3000000 1.49 1.29"])
self.assertAlmostEqual(mbps, 120.0, delta=2.0)
def test_every_stream_untimeable_reports_no_rate_not_zero(self):
# Zero is a claim about the line. None is the absence of one, and it
# is what the score treats as "no Speed component" rather than as a
# connection that carries nothing.
mbps, size = self.parallel(["3000000 0.101 0.09", "3000000 0.101 0.09"])
self.assertIsNone(mbps)
self.assertEqual(size, 6_000_000)
def test_bytes_are_counted_even_when_the_rate_is_withheld(self):
# The data budget was spent whether or not it produced a number.
_, size = self.parallel(["3000000 0.101 0.09", "3000000 0.29 0.09"])
self.assertEqual(size, 6_000_000)
class ParallelUploadAccounting(unittest.TestCase):
"""Upload was measured on one stream, in both the check and the peak.
The download learned in 0.1.x that a single TCP stream reads its own
ceiling rather than the line's, and grew `_parallel_download` for it. The
upload never did. Measured against speed.cloudflare.com on a ~450 Mbps
line: the same 2 MB carried by four streams read 36% higher than by one.
"""
class FakeProc:
def __init__(self, out, rc=0):
self._out, self.returncode, self.fed = out, rc, None
def communicate(self, input=None, timeout=None):
self.fed = input
return self._out, None
def kill(self):
pass
def wait(self):
pass
def parallel(self, outputs, per_stream=1_000_000):
from nexthopd import speedtest
queue = list(outputs)
made = []
real = speedtest.subprocess.Popen
def fake(*a, **k):
proc = self.FakeProc(queue.pop(0))
made.append(proc)
return proc
speedtest.subprocess.Popen = fake
try:
got = speedtest._parallel_upload("https://x/y", per_stream,
len(outputs), 5)
finally:
speedtest.subprocess.Popen = real
return got, made
def test_overlapping_streams_are_the_bytes_over_the_time_they_took(self):
out = b"1000000 0.29 0.09" # 4 MB in a shared 200 ms window
(mbps, size), _ = self.parallel([out, out, out, out])
self.assertAlmostEqual(mbps, 160.0, delta=1.0)
self.assertEqual(size, 4_000_000)
def test_a_stream_that_outlives_the_others_does_not_double_the_line(self):
(mbps, size), _ = self.parallel(
[b"1000000 0.29 0.09", b"1000000 0.49 0.29"])
self.assertAlmostEqual(mbps, 40.0, delta=1.0)
self.assertEqual(size, 2_000_000)
def test_every_stream_is_fed_the_same_buffer(self):
# One body, N readers: the memory cost is a stream's worth, not N.
out = b"1000000 0.29 0.09"
_, made = self.parallel([out, out, out])
self.assertEqual(len(made), 3)
self.assertEqual(len(made[0].fed), 1_000_000)
for proc in made[1:]:
self.assertIs(proc.fed, made[0].fed)
def test_every_stream_untimeable_reports_no_rate_not_zero(self):
out = b"1000000 0.101 0.09" # 11 ms window: below the floor
(mbps, size), _ = self.parallel([out, out])
self.assertIsNone(mbps)
self.assertEqual(size, 2_000_000)
def test_a_failed_stream_costs_its_own_share_and_no_more(self):
from nexthopd import speedtest
queue = [b"1000000 0.29 0.09", b""]
made = []
real = speedtest.subprocess.Popen
def fake(*a, **k):
proc = self.FakeProc(queue.pop(0), rc=0 if len(made) == 0 else 1)
made.append(proc)
return proc
speedtest.subprocess.Popen = fake
try:
mbps, size = speedtest._parallel_upload("https://x/y", 1_000_000, 2, 5)
finally:
speedtest.subprocess.Popen = real
self.assertAlmostEqual(mbps, 40.0, places=4)
self.assertEqual(size, 1_000_000)
def test_the_upload_invocation_keeps_its_scheme_floor_and_timing_field(self):
from nexthopd import speedtest
argv = speedtest._upload_argv("https://x/y", 30)
self.assertIn("--proto", argv)
self.assertIn("=https", argv)
joined = " ".join(argv)
self.assertIn("%{time_appconnect}", joined)
self.assertNotIn("%{speed_upload}", joined)
self.assertNotIn("%{time_starttransfer}", joined)
class ContentStreamSizing(unittest.TestCase):
"""Each stream is sized for a duration, not by dividing a budget.
Dividing a fixed budget is how both speed defects worked: more streams
meant shorter streams, and a stream too short to time is withheld. It also
charged the wrong people — 16 MB is a quarter-second on a fast line and
nine seconds of a saturated link on a 10 Mbps one, every hour.
"""
def sized(self, hint, cap=None, streams=4):
from nexthopd import speedtest
if cap is None:
cap = speedtest.CONTENT_DOWN_STREAM_CAP
return speedtest.content_stream_bytes(hint, streams, cap)
def test_a_slow_line_is_asked_for_far_less(self):
from nexthopd import speedtest
# 10 Mbps over four streams: 2.5 Mbps a stream, half a second of it.
self.assertEqual(self.sized(10.0), speedtest.CONTENT_STREAM_FLOOR)
# 100 Mbps: 25 Mbps a stream, so about 1.5 MB - still under the cap.
self.assertLess(self.sized(100.0), speedtest.CONTENT_DOWN_STREAM_CAP)
self.assertGreater(self.sized(100.0), speedtest.CONTENT_STREAM_FLOOR)
def test_a_fast_line_reaches_the_cap_and_stops(self):
from nexthopd import speedtest
self.assertEqual(self.sized(1000.0), speedtest.CONTENT_DOWN_STREAM_CAP)
self.assertEqual(self.sized(10000.0), speedtest.CONTENT_DOWN_STREAM_CAP)
def test_the_size_never_leaves_its_bounds(self):
from nexthopd import speedtest
for hint in (0.001, 1, 10, 50, 250, 900, 5000):
n = self.sized(hint)
self.assertGreaterEqual(n, speedtest.CONTENT_STREAM_FLOOR)
self.assertLessEqual(n, speedtest.CONTENT_DOWN_STREAM_CAP)
def test_no_hint_sends_the_cap(self):
# The first check on a network has nothing to size against, and it is
# the one that produces the hint every later check uses.
from nexthopd import speedtest
for hint in (None, 0, -5):
self.assertEqual(self.sized(hint), speedtest.CONTENT_DOWN_STREAM_CAP)
def test_a_stream_sized_for_a_rate_can_be_timed_at_that_rate(self):
"""The property that makes the whole thing safe.
Whatever the hint, the stream it produces carries payload for longer
than MIN_TIMED_WINDOW_S at that same rate — so sizing can never
produce a transfer its own accounting would then withhold.
"""
from nexthopd import speedtest
for cap in (speedtest.CONTENT_DOWN_STREAM_CAP,
speedtest.CONTENT_UP_STREAM_CAP):
for hint in (1, 10, 100, 400, 900, 1200):
n = self.sized(hint, cap=cap)
payload_s = (n * 8 / 1e6) / (hint / 4)
self.assertGreaterEqual(
payload_s, speedtest.MIN_TIMED_WINDOW_S,
"hint %s Mbps, cap %s -> %s bytes is %.3f s" % (
hint, cap, n, payload_s))
def test_the_line_speed_each_direction_can_still_time(self):
"""The ceiling, pinned. Past it the figure is withheld, not guessed.
Raising the upload cap moved this from ~640 Mbps to ~1.28 Gbps, which
covers the 820 Mbps line reported in issue #2 — and it costs nothing
on a slower line, because the cap is now a ceiling rather than a
constant everyone pays.
"""
from nexthopd import speedtest
streams = 4
def ceiling(cap):
return (cap * 8 / 1e6) / speedtest.MIN_TIMED_WINDOW_S * streams
self.assertAlmostEqual(ceiling(speedtest.CONTENT_DOWN_STREAM_CAP),
1920.0, places=1)
self.assertAlmostEqual(ceiling(speedtest.CONTENT_UP_STREAM_CAP),
1280.0, places=1)
def test_the_whole_check_stays_within_its_declared_budget(self):
"""What the README promises: up to ~20 MB, and only on a fast line."""
from nexthopd import speedtest
streams = 4
worst = (speedtest.CONTENT_DOWN_STREAM_CAP
+ speedtest.CONTENT_UP_STREAM_CAP) * streams
self.assertLessEqual(worst, 20_000_000)
typical = (self.sized(100.0, speedtest.CONTENT_DOWN_STREAM_CAP)
+ self.sized(20.0, speedtest.CONTENT_UP_STREAM_CAP)) * streams
self.assertLess(typical, worst / 2)
class AHintThatUnderSizesHealsItself(unittest.TestCase):
"""The deadlock this avoids.
A stream sized from a stale-low hint can finish inside the window too
short to time, and be withheld. Nothing is stored for a withheld check,
so the hint never learns better — every check after it would ask for the
same too-short transfer and report nothing, on a line that is simply
faster than its own history. Observed for real: a 25 Mbps hint on this
~450 Mbps line returned `down: None`.
"""
def run_check(self, down_results, up_results, **kwargs):
from nexthopd import speedtest
d_calls, u_calls = [], []
real_d, real_u = speedtest._parallel_download, speedtest._parallel_upload
def fake_down(url, streams, timeout):
d_calls.append(url)
return down_results.pop(0)
def fake_up(url, per_stream, streams, timeout):
u_calls.append(per_stream)
return up_results.pop(0)
speedtest._parallel_download = fake_down
speedtest._parallel_upload = fake_up
try:
return speedtest.content_test(**kwargs), d_calls, u_calls
finally:
speedtest._parallel_download = real_d
speedtest._parallel_upload = real_u
def test_a_withheld_download_is_retried_once_at_the_cap(self):
from nexthopd import speedtest
r, d_calls, _ = self.run_check(
[(None, 2_000_000), (420.0, 12_000_000)],
[(90.0, 2_000_000)],
down_hint_mbps=25.0, up_hint_mbps=20.0)
self.assertEqual(len(d_calls), 2)
self.assertIn(str(speedtest.CONTENT_DOWN_STREAM_CAP), d_calls[1])
self.assertEqual(r["down_mbps"], 420.0)
# Both passes were paid for; the budget must say so.
self.assertEqual(r["bytes"], 2_000_000 + 12_000_000 + 2_000_000)
def test_a_withheld_upload_is_retried_once_at_the_cap(self):
from nexthopd import speedtest
r, _, u_calls = self.run_check(
[(420.0, 12_000_000)],
[(None, 1_000_000), (95.0, 8_000_000)],
down_hint_mbps=900.0, up_hint_mbps=20.0)
self.assertEqual(len(u_calls), 2)
self.assertEqual(u_calls[1], speedtest.CONTENT_UP_STREAM_CAP)
self.assertEqual(r["up_mbps"], 95.0)
def test_a_check_already_at_the_cap_is_not_retried(self):
# Nothing larger to ask for: retrying would spend the budget twice
# to arrive at the same answer.
r, d_calls, _ = self.run_check(
[(None, 12_000_000)], [(None, 8_000_000)])
self.assertEqual(len(d_calls), 1)
self.assertIsNone(r["down_mbps"])
self.assertFalse(r["ok"])
def test_a_genuinely_slow_line_is_not_retried(self):
# A small transfer that produced a number is a good measurement, not
# a failed one. This is the common case and it must stay cheap.
r, d_calls, u_calls = self.run_check(
[(9.5, 2_000_000)], [(2.1, 2_000_000)],
down_hint_mbps=10.0, up_hint_mbps=2.0)
self.assertEqual(len(d_calls), 1)
self.assertEqual(len(u_calls), 1)
self.assertEqual(r["bytes"], 4_000_000)
class PeakUploadCostIsUnchangedByParallelism(unittest.TestCase):
"""Splitting a pass must not multiply what it spends.
The peak's sustained upload used to be one stream of up to PEAK_UP_CAP.
It is now PEAK_STREAMS of a per-stream size, and the point of sizing per
stream rather than per pass is that the total stays where it was.
"""
def test_the_cap_still_bounds_the_whole_pass(self):
from nexthopd import speedtest
per_stream_cap = speedtest.PEAK_UP_CAP // speedtest.PEAK_STREAMS
self.assertLessEqual(per_stream_cap * speedtest.PEAK_STREAMS,
speedtest.PEAK_UP_CAP)
def test_a_pass_is_sized_for_the_rate_one_stream_carries(self):
from nexthopd import speedtest
# 400 Mbps over four streams is 100 Mbps a stream; ten seconds of that
# is 125 MB, which the per-stream cap holds down to 25 MB.
n = speedtest._sized_pass(400.0 / speedtest.PEAK_STREAMS,
speedtest.PEAK_UP_FLOOR // speedtest.PEAK_STREAMS,
speedtest.PEAK_UP_CAP // speedtest.PEAK_STREAMS)
self.assertLessEqual(n * speedtest.PEAK_STREAMS, speedtest.PEAK_UP_CAP)
self.assertGreaterEqual(n, speedtest.PEAK_UP_FLOOR // speedtest.PEAK_STREAMS)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,180 @@
"""Tests for state.py and paths.py — atomic writes, bounded reads, where the files live.
Run: python3 -m unittest discover -s test
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd.state import write_atomic, read_json # noqa: E402
class AtomicState(unittest.TestCase):
def test_write_read(self):
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "live.json"
write_atomic(p, {"x": 1})
self.assertEqual(read_json(p), {"x": 1})
self.assertEqual(read_json(Path(d) / "missing.json", 42), 42)
# No temp files left behind.
self.assertEqual([f.name for f in Path(d).iterdir()], ["live.json"])
class StateReadSafety(unittest.TestCase):
"""The read the QML side consumes: bounded, non-blocking, no-follow,
regular files only. Every property is enforced on the fd actually read,
so a state file swapped at its predictable path can neither redirect
the read, stall it, nor allocate without limit."""
def setUp(self):
from nexthopd.state import read_text_bounded
self.read = read_text_bounded
self.tmp = tempfile.TemporaryDirectory()
self.d = Path(self.tmp.name)
def tearDown(self):
self.tmp.cleanup()
def test_regular_file_reads(self):
(self.d / "live.json").write_text('{"a":1}')
got = self.read(self.d / "live.json", 1024)
self.assertIsNotNone(got)
self.assertEqual(got[0], '{"a":1}')
def test_symlink_refused_and_target_unread(self):
secret = self.d / "secret"
secret.write_text("SENSITIVE")
(self.d / "live.json").symlink_to(secret)
self.assertIsNone(self.read(self.d / "live.json", 1024))
def test_fifo_refused_without_blocking(self):
import os as _os
_os.mkfifo(self.d / "live.json")
# No writer will ever open this; a blocking open would hang here.
self.assertIsNone(self.read(self.d / "live.json", 1024))
def test_oversized_refused_by_the_read_itself(self):
(self.d / "live.json").write_text("x" * 5000)
self.assertIsNone(self.read(self.d / "live.json", 1024))
def test_exactly_at_the_cap_is_allowed(self):
(self.d / "live.json").write_text("y" * 1024)
got = self.read(self.d / "live.json", 1024)
self.assertIsNotNone(got)
self.assertEqual(len(got[0]), 1024)
def test_directory_refused(self):
self.assertIsNone(self.read(self.d, 1024))
def test_missing_file_is_none(self):
self.assertIsNone(self.read(self.d / "nope.json", 1024))
def test_stamp_changes_only_when_the_file_does(self):
p = self.d / "live.json"
p.write_text('{"a":1}')
first = self.read(p, 1024)[1]
self.assertEqual(self.read(p, 1024)[1], first)
import os as _os
p.write_text('{"a":2}')
_os.utime(p, ns=(0, 12345))
self.assertNotEqual(self.read(p, 1024)[1], first)
def test_stream_keys_are_a_closed_set(self):
from nexthopd.cli import STREAMABLE
# The QML side names a key, never a path — nothing it passes can
# widen what gets opened.
self.assertEqual(sorted(STREAMABLE),
["apps", "live", "manifest", "recent"])
def test_indented_json_still_streams_as_one_line(self):
# manifest.json is pretty-printed. Forwarding it verbatim would
# emit several lines and the shell service would never learn the
# version, silently disabling the update handover.
import json as _json
p = self.d / "manifest.json"
p.write_text(_json.dumps({"version": "9.9.9", "kinds": ["service"]},
indent=2))
text = self.read(p, 1024)[0]
self.assertIn("\n", text)
line = _json.dumps(_json.loads(text), separators=(",", ":"))
self.assertNotIn("\n", line)
self.assertEqual(_json.loads(line)["version"], "9.9.9")
def test_embedded_newline_cannot_break_framing(self):
import json as _json
p = self.d / "live.json"
p.write_text(_json.dumps({"note": "one\ntwo", "v": 1}))
text = self.read(p, 4096)[0]
line = _json.dumps(_json.loads(text), separators=(",", ":"))
self.assertNotIn("\n", line)
self.assertEqual(_json.loads(line)["note"], "one\ntwo")
class VolatileSnapshotsLiveInTheRuntimeDir(unittest.TestCase):
def test_runtime_dir_prefers_xdg_runtime_dir(self):
from nexthopd import paths
saved = dict(os.environ)
self.addCleanup(lambda: (os.environ.clear(), os.environ.update(saved)))
with tempfile.TemporaryDirectory() as tmp:
os.environ["XDG_RUNTIME_DIR"] = tmp
self.assertEqual(paths.runtime_dir(), Path(tmp) / "nexthop")
for fn in (paths.live_path, paths.recent_path, paths.apps_path):
self.assertEqual(fn().parent, Path(tmp) / "nexthop")
# History, settings and the lock never move.
for fn in (paths.db_path, paths.lock_path):
self.assertEqual(fn().parent, paths.state_dir())
# No usable runtime dir: everything stays together in the
# state dir, so a reader and a writer in the same environment
# always agree.
os.environ["XDG_RUNTIME_DIR"] = str(Path(tmp) / "missing")
self.assertEqual(paths.runtime_dir(), paths.state_dir())
os.environ.pop("XDG_RUNTIME_DIR")
self.assertEqual(paths.runtime_dir(), paths.state_dir())
def test_volatile_writes_do_not_fsync(self):
from nexthopd import state
calls = []
self.addCleanup(setattr, state.os, "fsync", state.os.fsync)
state.os.fsync = lambda fd: calls.append(fd)
with tempfile.TemporaryDirectory() as tmp:
write_atomic(Path(tmp) / "a.json", {"x": 1})
self.assertEqual(calls, [])
self.assertEqual(read_json(Path(tmp) / "a.json", None), {"x": 1})
write_atomic(Path(tmp) / "b.json", {"x": 1}, durable=True)
self.assertEqual(len(calls), 1)
def test_legacy_snapshots_are_retired_with_a_tombstone(self):
from nexthopd import paths, state
with tempfile.TemporaryDirectory() as tmp:
old = Path(tmp) / "state"
old.mkdir()
for name in (paths.RECENT, paths.APPS):
(old / name).write_text("{}")
write_atomic(old / paths.LIVE, {"v": 1, "state": "online", "index": 93,
"t": 1.0, "pid": 42, "pid_start": 7,
"daemon_version": "0.2.21",
"link": {"ssid": "Home"}})
state.retire_legacy_snapshots(old, Path(tmp) / "runtime", now=2.0)
self.assertFalse((old / paths.RECENT).exists())
self.assertFalse((old / paths.APPS).exists())
tomb = read_json(old / paths.LIVE, None)
# An old reader shows "no data", not the last number it saw...
self.assertEqual(tomb["state"], "no-daemon")
self.assertIsNone(tomb["index"])
self.assertEqual(tomb["t"], 2.0)
# ...and an old version watch finds no pid to retire.
for key in ("pid", "pid_start", "daemon_version"):
self.assertNotIn(key, tomb)
self.assertEqual(tomb["link"], {"ssid": "Home"}) # shape kept
# Same directory (no runtime dir available): nothing to retire.
state.retire_legacy_snapshots(old, old, now=3.0)
self.assertEqual(read_json(old / paths.LIVE, None)["t"], 2.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,388 @@
"""Tests for store.py — rows, rollups, events, and the one connection three threads share.
Run: python3 -m unittest discover -s test
"""
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd.store import Store # noqa: E402
class StoreRoundtrip(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.store = Store(Path(self.dir.name) / "t.db")
def tearDown(self):
self.store.close()
self.dir.cleanup()
def test_minute_hour_resolution_switch(self):
now = int(time.time())
for i in range(300):
self.store.put_minute(now - (300 - i) * 60, {"lag": 20.0})
rows, table = self.store.series(3600, now=now)
self.assertEqual(table, "minute")
self.assertEqual(len(rows), 60)
self.store.rollup_hours(now)
rows, table = self.store.series(7 * 86400, now=now)
self.assertEqual(table, "hour")
self.assertGreaterEqual(len(rows), 4)
def test_outage_accounting(self):
now = time.time()
eid = self.store.open_event(int(now - 600), "outage", "critical",
"wan", "test")
self.store.close_event(eid, int(now - 540))
frac, disruptions, disrupt_frac = self.store.outage_stats(3600, now=now)
self.assertAlmostEqual(frac, 60 / 3600, places=3)
self.assertEqual(disruptions, 0)
self.assertEqual(disrupt_frac, 0.0)
def test_ongoing_outage_counts_to_now(self):
now = time.time()
self.store.open_event(int(now - 120), "outage", "critical", "wan", "t")
frac, _, _ = self.store.outage_stats(3600, now=now)
self.assertAlmostEqual(frac, 120 / 3600, places=3)
def test_disruptions_report_duration_not_just_count(self):
now = time.time()
for start, length in ((900, 20), (600, 40)):
eid = self.store.open_event(int(now - start), "disruption",
"warning", "wan", "t")
self.store.close_event(eid, int(now - start + length))
frac, disruptions, disrupt_frac = self.store.outage_stats(3600, now=now)
self.assertEqual(frac, 0.0)
self.assertEqual(disruptions, 2)
self.assertAlmostEqual(disrupt_frac, 60 / 3600, places=3)
def test_baseline_is_p90_per_network(self):
now = int(time.time())
for i, v in enumerate([100, 200, 210, 220, 230, 240, 900]):
self.store.put_test(now - i * 3600, "content", "cloudflare",
down_mbps=v, ok=True, network="Office")
# p90 shrugs off the one lucky 900 run.
self.assertLess(self.store.baseline_speed(network="Office", now=now), 900)
self.assertGreaterEqual(self.store.baseline_speed(network="Office", now=now), 240)
# Too few samples on an unknown network falls back to all networks.
self.assertIsNotNone(self.store.baseline_speed(network="Home", now=now))
def test_baseline_no_fallback_without_local_samples(self):
now = int(time.time())
for i in range(6):
self.store.put_test(now - i * 3600, "content", "x",
down_mbps=300, ok=True, network="OfficeA")
# Another network's history must not become this network's normal.
self.assertIsNone(self.store.baseline_speed(network="OfficeB",
now=now, fallback=False))
self.assertIsNotNone(self.store.baseline_speed(network="OfficeB",
now=now))
def test_baseline_needs_enough_samples(self):
now = int(time.time())
for i in range(3):
self.store.put_test(now - i * 3600, "content", "x",
down_mbps=100, ok=True)
self.assertIsNone(self.store.baseline_speed(now=now))
class MinuteProvenance(unittest.TestCase):
def test_minute_rows_carry_basis_and_seats(self):
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
store = Store(Path(d.name) / "t.db")
self.addCleanup(store.close)
ts = int(time.time() // 60) * 60
store.put_minute(ts, {"lag": 30.0, "lag_icmp": 24.0},
iface="wlo1", network="x", probes="icmp-anchor+tcp-cf")
rows, _ = store.series(3600)
row = rows[-1]
self.assertEqual(row["lag_icmp"], 24.0)
self.assertEqual(row["probes"], "icmp-anchor+tcp-cf")
def test_old_databases_gain_the_columns(self):
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
path = Path(d.name) / "t.db"
import sqlite3 as sq
from nexthopd.store import SAMPLE_COLUMNS
old_cols = ", ".join(f"{c} REAL" for c in SAMPLE_COLUMNS
if c != "lag_icmp")
db = sq.connect(path)
db.execute(f"CREATE TABLE minute (ts INTEGER PRIMARY KEY, {old_cols}, "
"iface TEXT, network TEXT)")
db.commit(); db.close()
store = Store(path)
self.addCleanup(store.close)
store.put_minute(60, {"lag": 1.0}, probes="a+b") # must not raise
class TailStatisticsAreRecorded(unittest.TestCase):
"""p75 and max per leg, written but never scored.
Comparing our headline against Orb's and LibreQoS's could only be done
as an upper bound because neither statistic was ever stored; p50 and
p95 cannot reconstruct them. Recorded now so the choice can be argued
from real days rather than bounded — the rule 0.1.11 held loaded
latency to.
"""
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.path = Path(self.dir.name) / "history.db"
def tearDown(self):
self.dir.cleanup()
def test_the_columns_exist_and_round_trip(self):
st = Store(self.path)
st.put_minute(60, {"local_p50": 1.0, "local_p75": 2.0,
"local_max": 9.0, "wan_p50": 3.0,
"wan_p75": 4.0, "wan_max": 77.7})
row = st.db.execute("SELECT * FROM minute WHERE ts = 60").fetchone()
self.assertEqual(row["local_p75"], 2.0)
self.assertEqual(row["local_max"], 9.0)
self.assertEqual(row["wan_p75"], 4.0)
self.assertEqual(row["wan_max"], 77.7)
st.close()
def test_an_older_database_is_migrated_in_place(self):
# The invariant across nine schema-touching releases: additive
# ALTER TABLE, never a rewrite. An existing row must survive it.
import sqlite3 as sq
st = Store(self.path)
st.put_minute(60, {"local_p50": 1.0})
st.close()
db = sq.connect(self.path)
for col in ("local_p75", "local_max", "wan_p75", "wan_max"):
db.execute("ALTER TABLE minute DROP COLUMN %s" % col)
db.execute("ALTER TABLE hour DROP COLUMN %s" % col)
db.commit()
db.close()
st = Store(self.path) # reopening must migrate
cols = [r[1] for r in st.db.execute("PRAGMA table_info(minute)")]
for col in ("local_p75", "local_max", "wan_p75", "wan_max"):
self.assertIn(col, cols)
row = st.db.execute("SELECT * FROM minute WHERE ts = 60").fetchone()
self.assertEqual(row["local_p50"], 1.0) # the old row survived
self.assertIsNone(row["local_max"]) # and is honestly blank
st.close()
def test_they_are_recorded_but_not_scored(self):
# Nothing in the scoring path may read them yet. If that changes it
# should be a deliberate release, not a drift.
src = Path(__file__).resolve().parent.parent / "nexthopd" / "score.py"
text = src.read_text()
for col in ("local_p75", "local_max", "wan_p75", "wan_max"):
self.assertNotIn(col, text)
class StoreConcurrency(unittest.TestCase):
"""One connection shared by the loop and two test workers. Every call
must serialise: sqlite3 raises on an overlapping use of one connection
and the row it was writing is lost."""
def test_overlapping_writers_and_readers_lose_nothing(self):
import threading
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
store = Store(Path(d.name) / "t.db")
self.addCleanup(store.close)
errors, n = [], 150
def guard(fn):
def run():
try:
fn()
except Exception as e: # noqa: BLE001 — recorded, asserted
errors.append(repr(e))
return run
def tests(kind, base):
for i in range(n):
store.put_test(base + i, kind, "x", down_mbps=1.0, ok=True,
network="n")
def loop():
for i in range(n):
store.put_minute(1_000_000 + i * 60, {"lag": 1.0})
store.outage_stats(3600, now=2_000_000)
def events():
for i in range(n):
eid = store.open_event(3_000_000 + i, "outage", "critical",
"wan", "t")
store.close_event(eid, 3_000_000 + i + 1)
store.events(86400, now=3_000_000 + n)
threads = [threading.Thread(target=guard(lambda: tests("content", 10_000))),
threading.Thread(target=guard(lambda: tests("peak", 20_000))),
threading.Thread(target=guard(loop)),
threading.Thread(target=guard(events))]
for t in threads:
t.start()
for t in threads:
t.join(60)
self.assertEqual(errors, [])
self.assertEqual(len(store.tests(limit=1000, kind="content")), n)
self.assertEqual(len(store.tests(limit=1000, kind="peak")), n)
rows, _ = store.series(10 ** 9, now=1_000_000 + n * 60 + 1,
resolution="minute")
self.assertEqual(len(rows), n)
class EventWindowSemantics(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
self.store = Store(Path(self.dir.name) / "t.db")
self.addCleanup(self.store.close)
self.now = 1_000_000
def details(self, seconds):
return [e["detail"] for e in self.store.events(seconds, now=self.now)]
def test_an_event_that_ended_inside_the_window_is_listed(self):
# Began 25 h ago, ended an hour ago: it overlaps the last 24 h and
# is exactly the row the user opens the list to find.
eid = self.store.open_event(self.now - 90_000, "outage", "critical",
"wan", "straddles")
self.store.close_event(eid, self.now - 3_600)
# Began and ended before the window: not listed.
eid = self.store.open_event(self.now - 90_000, "outage", "critical",
"wan", "old")
self.store.close_event(eid, self.now - 89_000)
listed = self.details(86_400)
self.assertIn("straddles", listed)
self.assertNotIn("old", listed)
def test_orphans_are_closed_at_the_shortest_span(self):
self.store.open_event(self.now - 90_000, "outage", "critical", "wan",
"orphan")
# Left NULL, it charges every window forever.
frac, _, _ = self.store.outage_stats(3600, now=self.now)
self.assertAlmostEqual(frac, 1.0, places=3)
self.assertEqual(self.store.close_orphans(self.now), 1)
frac, _, _ = self.store.outage_stats(3600, now=self.now)
self.assertEqual(frac, 0.0)
row = self.store.events(10 ** 6, now=self.now)[0]
self.assertEqual(row["ended_ts"], row["ts"] + 1)
# Idempotent, and it never touches a properly closed row.
self.assertEqual(self.store.close_orphans(self.now), 0)
def test_prune_removes_events_past_the_hourly_horizon(self):
eid = self.store.open_event(self.now - 500 * 86_400, "info", "info",
"local", "ancient")
self.store.close_event(eid, self.now - 500 * 86_400 + 1)
eid = self.store.open_event(self.now - 10, "info", "info", "local",
"recent")
self.store.close_event(eid, self.now - 9)
self.store.prune(now=self.now)
listed = self.details(10 ** 9)
self.assertNotIn("ancient", listed)
self.assertIn("recent", listed)
class RollupNetworkLabel(unittest.TestCase):
def test_an_hour_spanning_two_networks_is_labelled_neither(self):
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
store = Store(Path(d.name) / "t.db")
self.addCleanup(store.close)
hour = 3_600_000
store.put_minute(hour + 60, {"lag": 1.0}, iface="wlo1", network="Home")
store.put_minute(hour + 120, {"lag": 1.0}, iface="wlo1", network="Office")
store.put_minute(hour + 3660, {"lag": 1.0}, iface="wlo1", network="Office")
store.rollup_hours(now=hour + 3 * 3600)
rows, _ = store.series(10 ** 6, now=hour + 3 * 3600, resolution="hour")
by_ts = {r["ts"]: r for r in rows}
self.assertEqual(by_ts[hour]["network"], "")
self.assertEqual(by_ts[hour]["iface"], "wlo1")
self.assertEqual(by_ts[hour + 3600]["network"], "Office")
class DrainIsStored(unittest.TestCase):
"""The drain was published and never stored, so it could not be audited.
That is why the distribution proving it is quantised by probe cadence had
to come from the other implementation: this one had no history to look at.
"""
def store(self):
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
s = Store(Path(d.name) / "t.db")
self.addCleanup(s.close)
return s
def test_a_minute_carries_the_drain_and_what_produced_it(self):
s = self.store()
s.put_minute(60, {"lag": 12.0, "drain_ms": 2000.0,
"drain_min_ms": 1000.0, "drain_settled": 1.0,
"drain_src": "tcp-anchor"},
iface="wlan0", network="home", probes="icmp+tcp")
rows, _ = s.series(seconds=3600, now=120, resolution="minute")
row = rows[0]
self.assertEqual(row["drain_ms"], 2000.0)
self.assertEqual(row["drain_min_ms"], 1000.0)
self.assertEqual(row["drain_settled"], 1.0)
self.assertEqual(row["drain_src"], "tcp-anchor")
def test_never_measured_and_did_not_settle_stay_different(self):
# None is "no drain in this minute"; 0.0 is "measured, never came
# back inside the window". Collapsing them would turn a censored
# observation into a real one.
s = self.store()
s.put_minute(60, {"drain_ms": 30000.0, "drain_settled": 0.0}, network="home")
s.put_minute(120, {"lag": 9.0}, network="home")
got, _ = s.series(seconds=3600, now=180, resolution="minute")
rows = {r["ts"]: r for r in got}
self.assertEqual(rows[60]["drain_settled"], 0.0)
self.assertIsNone(rows[120]["drain_settled"])
self.assertIsNone(rows[120]["drain_ms"])
def test_the_drain_does_not_roll_up_into_an_hour(self):
"""A mean of drains destroys the only thing it is stored for.
The value is quantised by probe cadence, so what has to survive is
the distribution. Sixty of them averaged has none of that in it —
the same objection the rollup already carries for percentiles, but
binding harder, because here the spread IS the finding.
"""
from nexthopd.store import SAMPLE_COLUMNS
for c in ("drain_ms", "drain_min_ms", "drain_settled", "drain_src"):
self.assertNotIn(c, SAMPLE_COLUMNS)
s = self.store()
cols = [r[1] for r in s.db.execute("PRAGMA table_info(hour)")]
for c in ("drain_ms", "drain_min_ms", "drain_settled", "drain_src"):
self.assertNotIn(c, cols)
def test_an_older_database_gains_the_columns(self):
# Additive migration only, as every schema change here has been.
d = tempfile.TemporaryDirectory()
self.addCleanup(d.cleanup)
path = Path(d.name) / "old.db"
old = sqlite3.connect(path)
old.execute("CREATE TABLE minute (ts INTEGER PRIMARY KEY, lag REAL)")
old.execute("CREATE TABLE hour (ts INTEGER PRIMARY KEY, lag REAL)")
old.execute("CREATE TABLE tests (ts INTEGER, kind TEXT)")
old.execute("CREATE TABLE events (ts INTEGER, kind TEXT)")
old.commit()
old.close()
s = Store(path)
self.addCleanup(s.close)
cols = [r[1] for r in s.db.execute("PRAGMA table_info(minute)")]
for c in ("drain_ms", "drain_min_ms", "drain_settled", "drain_src"):
self.assertIn(c, cols)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,252 @@
"""Tests for update.py — notify, never install.
Run: python3 -m unittest discover -s test
"""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nexthopd.daemon import Config # noqa: E402
from nexthopd.update import RE_SHA, UpdateWatch, verdict # noqa: E402
from support import REPO, run_inline # noqa: E402
class UpdateNotice(unittest.TestCase):
"""The update check reports; it must never act, and never trust a remote
string far enough to hand it to a subprocess unchecked."""
A = "a" * 40
B = "b" * 40
def test_verdict_states(self):
self.assertEqual(verdict(self.A, self.A, True, False, False), "current")
# Origin holds a commit we have never seen.
self.assertEqual(verdict(self.A, self.B, False, False, False), "behind")
# THE CASE THAT MATTERS: `omarchy plugin update` fetches before it
# shows its diff, so a user who looked and declined already holds
# origin's commit while still being behind it. Deciding from "we have
# never seen that object" alone would show that user nothing.
self.assertEqual(verdict(self.A, self.B, True, True, False), "behind")
# A developer checkout ahead of origin must not be nagged.
self.assertEqual(verdict(self.A, self.B, True, False, True), "ahead")
self.assertEqual(verdict(self.A, self.B, True, False, False), "diverged")
# Not knowing is its own answer, not a guess in either direction.
self.assertEqual(verdict("", self.B, False, False, False), "unknown")
self.assertEqual(verdict(self.A, "", False, False, False), "unknown")
def test_remote_sha_must_be_an_object_id(self):
# This is the guard that matters: the value arrives from the network
# and is then passed as an argument to git.
for bad in ("", "HEAD", "a" * 39, "a" * 41, "A" * 40, "g" * 40,
"../../etc/passwd", "a" * 40 + " --upload-pack=sh",
"--upload-pack=evil", "a" * 40 + "\n" + "b" * 40):
self.assertIsNone(RE_SHA.match(bad), bad)
self.assertIsNotNone(RE_SHA.match(self.A))
def test_junk_from_the_remote_yields_unknown_not_a_crash(self):
w = UpdateWatch(repo=REPO)
calls = []
def fake(*args, capture=True):
calls.append(args)
if args[0] == "rev-parse":
return 0, self.A
if args[0] == "ls-remote":
return 0, "not-a-sha\tHEAD"
return 0, ""
w._git = fake
self.assertEqual(w.check(), "unknown")
# Having refused the answer, it must not go on to use it.
self.assertNotIn("cat-file", [c[0] for c in calls])
def test_behind_is_reported_without_any_write(self):
w = UpdateWatch(repo=REPO)
seen = []
def fake(*args, capture=True):
seen.append(args[0])
if args[0] == "rev-parse":
return 0, self.A
if args[0] == "ls-remote":
return 0, self.B + "\tHEAD"
if args[0] == "cat-file":
return 1, "" # we do not hold origin's commit
return 0, ""
w._git = fake
self.assertEqual(w.check(), "behind")
# Every git verb used must be read-only. A fetch, pull, merge or
# checkout here would make this self-updating code.
self.assertTrue(set(seen) <= {"rev-parse", "ls-remote", "cat-file",
"merge-base"}, seen)
def test_cadence_delays_the_first_check_and_then_spaces_them(self):
w = UpdateWatch(repo=REPO, spawn=run_inline)
w.check = lambda: "behind"
w.tick(1000.0)
# Nothing on the first tick: the daemon restarts with the shell, and
# a check on every restart would be noise.
self.assertIsNone(w.checked_ts)
w.tick(1000.0 + 299)
self.assertIsNone(w.checked_ts)
w.tick(1000.0 + 301)
self.assertEqual(w.state, "behind")
self.assertTrue(w.snapshot()["available"])
# ...and then not again for a day.
first = w.checked_ts
w.check = lambda: "current"
w.tick(1000.0 + 3600)
self.assertEqual(w.checked_ts, first)
w.tick(1000.0 + 301 + 24 * 3600 + 1)
self.assertEqual(w.state, "current")
self.assertFalse(w.snapshot()["available"])
def test_disabled_makes_no_check_and_clears_any_notice(self):
w = UpdateWatch(repo=REPO, spawn=run_inline)
w.check = lambda: "behind"
w.tick(1000.0)
w.tick(1000.0 + 301)
self.assertTrue(w.snapshot()["available"])
# Turning the setting off must retract the notice, not leave a stale
# one on screen.
w.enabled = False
called = []
w.check = lambda: called.append(1) or "behind"
w.tick(1000.0 + 301 + 24 * 3600 + 1)
self.assertEqual(called, [])
self.assertIsNone(w.snapshot())
def test_nothing_published_until_something_is_known(self):
w = UpdateWatch(repo=REPO)
self.assertIsNone(w.snapshot())
def test_real_checkout_is_read_only_and_answers(self):
"""Against this actual repository: a real answer, and the working
tree and refs are untouched afterwards."""
before = subprocess.run(["git", "-C", str(REPO), "status",
"--porcelain"], capture_output=True, text=True)
head_before = subprocess.run(["git", "-C", str(REPO), "rev-parse", "HEAD"],
capture_output=True, text=True).stdout
w = UpdateWatch(repo=REPO)
self.assertIn(w.check(), ("current", "behind", "ahead", "diverged",
"unknown"))
after = subprocess.run(["git", "-C", str(REPO), "status",
"--porcelain"], capture_output=True, text=True)
head_after = subprocess.run(["git", "-C", str(REPO), "rev-parse", "HEAD"],
capture_output=True, text=True).stdout
self.assertEqual(before.stdout, after.stdout)
self.assertEqual(head_before, head_after)
def test_real_clone_one_commit_behind_reports_behind(self):
"""End to end against real git: a checkout that already holds
origin's commit but sits one behind it must offer the update."""
with tempfile.TemporaryDirectory() as tmp:
clone = Path(tmp) / "c"
r = subprocess.run(["git", "clone", "--quiet", str(REPO), str(clone)],
capture_output=True)
if r.returncode != 0:
self.skipTest("git clone unavailable")
head = subprocess.run(["git", "-C", str(clone), "rev-parse", "HEAD~1"],
capture_output=True, text=True)
if head.returncode != 0:
self.skipTest("shallow history")
subprocess.run(["git", "-C", str(clone), "reset", "--quiet",
"--hard", "HEAD~1"], check=True,
capture_output=True)
w = UpdateWatch(repo=clone, spawn=run_inline)
self.assertEqual(w.check(), "behind")
self.assertTrue(w.snapshot() is None) # nothing until tick() runs
w.tick(1000.0)
w.tick(1000.0 + 301)
self.assertTrue(w.snapshot()["available"])
def test_config_default_is_on_and_validates_as_a_boolean(self):
cfg = Config.SCHEMA["updateCheck"]
self.assertTrue(cfg[0])
self.assertIs(cfg[1](True), True)
self.assertIs(cfg[1](False), False)
# A wrong type falls back to the default rather than being coerced,
# the same rule every other setting follows.
self.assertIsNone(cfg[1]("yes"))
def test_manifest_exposes_the_setting(self):
m = json.loads((REPO / "manifest.json").read_text())
entry = next(e for e in m["barWidget"]["schema"]
if e["key"] == "updateCheck")
self.assertEqual(entry["type"], "boolean")
self.assertIs(entry["defaultValue"], True)
self.assertIn("never installs", entry["description"])
class UpdateCheckIsPinnedToThePlugin(unittest.TestCase):
"""`git -C <dir>` walks up until it finds a repository. A copy-install
with no .git of its own inside a dotfiles checkout must answer unknown,
not the dotfiles' status — and must not contact the dotfiles' remote."""
def test_a_plain_directory_inside_a_repo_is_not_that_repo(self):
import shutil, subprocess
if not shutil.which("git"):
self.skipTest("git not installed")
with tempfile.TemporaryDirectory() as d:
subprocess.run(["git", "init", "-q", d], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
sub = Path(d) / "plugins" / "io.github.x3me.nexthop"
sub.mkdir(parents=True)
w = UpdateWatch(repo=sub)
self.assertIsNone(w._local_head())
self.assertEqual(w.check(), "unknown")
class UpdateCheckOffTheLoop(unittest.TestCase):
"""The daily check may sit at git's 20 s timeout on a bad network; the
loop that owns the outage watch must never wait for it."""
def watch(self):
held = []
w = UpdateWatch(repo=REPO, spawn=held.append) # records, never runs
w.check = lambda: "behind"
return w, held
def test_tick_returns_before_the_check_finishes(self):
w, held = self.watch()
w.tick(1000.0)
w.tick(1000.0 + 301)
self.assertEqual(len(held), 1)
self.assertEqual(w.state, "unknown") # nothing adopted yet
self.assertIsNone(w.snapshot())
held[0]() # the worker finishes
w.tick(1000.0 + 302)
self.assertEqual(w.state, "behind")
self.assertEqual(w.checked_ts, 1302)
def test_no_second_check_while_one_is_in_flight(self):
w, held = self.watch()
w.tick(1000.0)
w.tick(1000.0 + 301)
w.tick(1000.0 + 301 + 24 * 3600 + 1) # due again, first never returned
self.assertEqual(len(held), 1)
def test_disabling_drops_an_answer_still_in_flight(self):
w, held = self.watch()
w.tick(1000.0)
w.tick(1000.0 + 301)
w.enabled = False
w.tick(1000.0 + 302)
held[0]()
w.tick(1000.0 + 303)
self.assertIsNone(w.snapshot())
w.enabled = True
w.tick(1000.0 + 304)
# Re-enabled: the answer that landed while off must not surface.
self.assertIsNone(w.snapshot())
if __name__ == "__main__":
unittest.main()