- 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
1216 lines
49 KiB
Python
1216 lines
49 KiB
Python
"""Tests for daemon.py — config, the leg watches and arbiters, captive detection, the loop's helpers.
|
|
|
|
Run: python3 -m unittest discover -s test
|
|
"""
|
|
|
|
import os
|
|
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 daemon as daemon_mod # noqa: E402
|
|
from nexthopd import score # noqa: E402
|
|
from nexthopd.daemon import ( # noqa: E402
|
|
CHECK_DEFER_MAX_S,
|
|
CHECK_SETTLE_S,
|
|
DISRUPTION_AFTER_S,
|
|
LEG_STALE_S,
|
|
MIN_PLAUSIBLE_INFLATION,
|
|
NOTIFY_AFTER_S,
|
|
OUTAGE_AFTER_S,
|
|
PEAK_FRESH_S,
|
|
CaptiveWatch,
|
|
Config,
|
|
Daemon,
|
|
LOAD_FLOOR_BPS,
|
|
LegState,
|
|
LegWatch,
|
|
LocalEventArbiter,
|
|
WanEventArbiter,
|
|
check_ready,
|
|
leg_state)
|
|
from nexthopd.net import trace_verdict # noqa: E402
|
|
from nexthopd.store import Store # noqa: E402
|
|
from support import REPO, run_now, FakeStore, _FakeDaemonForDisruption # noqa: E402
|
|
|
|
|
|
class ConfigValidation(unittest.TestCase):
|
|
def make(self, payload):
|
|
d = tempfile.TemporaryDirectory()
|
|
self.addCleanup(d.cleanup)
|
|
cfg = Config(Path(d.name))
|
|
cfg.path.write_text(payload)
|
|
cfg.refresh()
|
|
return cfg
|
|
|
|
def test_out_of_range_values_fall_back_to_defaults(self):
|
|
cfg = self.make('{"throughputWindowS": 999999999, "probeIntervalMs": 1,'
|
|
' "historyDays": -5}')
|
|
self.assertEqual(cfg["throughputWindowS"], 3)
|
|
self.assertEqual(cfg["probeIntervalMs"], 500)
|
|
self.assertEqual(cfg["historyDays"], 7)
|
|
|
|
def test_wrong_types_rejected(self):
|
|
cfg = self.make('{"probeIntervalMs": "500", "contentSpeed": "yes",'
|
|
' "peakEngine": "evil"}')
|
|
self.assertEqual(cfg["probeIntervalMs"], 500)
|
|
self.assertEqual(cfg["contentSpeed"], True)
|
|
self.assertEqual(cfg["peakEngine"], "Auto")
|
|
|
|
def test_anchor_charset_enforced(self):
|
|
cfg = self.make('{"internetAnchor": "1.1.1.1; rm -rf /"}')
|
|
self.assertEqual(cfg["internetAnchor"], "1.1.1.1")
|
|
cfg2 = self.make('{"internetAnchor": "ping.example-host.net"}')
|
|
self.assertEqual(cfg2["internetAnchor"], "ping.example-host.net")
|
|
|
|
def test_oversized_file_ignored(self):
|
|
cfg = self.make('{"historyDays": 30, "pad": "' + 'x' * (70 * 1024) + '"}')
|
|
self.assertEqual(cfg["historyDays"], 7)
|
|
|
|
def test_valid_values_accepted(self):
|
|
cfg = self.make('{"throughputWindowS": 10, "planDownMbps": 450}')
|
|
self.assertEqual(cfg["throughputWindowS"], 10)
|
|
self.assertEqual(cfg["planDownMbps"], 450)
|
|
|
|
|
|
class FdSafety(unittest.TestCase):
|
|
def test_config_symlink_refused(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
target = Path(d) / "target.json"
|
|
target.write_text('{"historyDays": 30}')
|
|
cfg = Config(Path(d) / "sub")
|
|
cfg.path.parent.mkdir()
|
|
cfg.path.symlink_to(target)
|
|
cfg.refresh()
|
|
# A symlinked config is refused outright (O_NOFOLLOW).
|
|
self.assertEqual(cfg["historyDays"], 7)
|
|
|
|
def test_config_bound_is_on_the_read(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
cfg = Config(Path(d))
|
|
cfg.path.write_text('{"pad": "' + 'x' * (70 * 1024)
|
|
+ '", "historyDays": 30}')
|
|
cfg.refresh()
|
|
self.assertEqual(cfg["historyDays"], 7)
|
|
|
|
def test_lock_symlink_never_truncates_target(self):
|
|
import os as _os
|
|
from nexthopd.daemon import Daemon
|
|
with tempfile.TemporaryDirectory() as d:
|
|
victim = Path(d) / "victim"
|
|
victim.write_text("precious data that must survive")
|
|
_os.environ["XDG_STATE_HOME"] = d
|
|
try:
|
|
state = Path(d) / "nexthop"
|
|
state.mkdir()
|
|
(state / "nexthopd.lock").symlink_to(victim)
|
|
daemon = Daemon()
|
|
try:
|
|
self.assertFalse(daemon.acquire_lock())
|
|
finally:
|
|
daemon.store.close()
|
|
finally:
|
|
del _os.environ["XDG_STATE_HOME"]
|
|
self.assertEqual(victim.read_text(),
|
|
"precious data that must survive")
|
|
|
|
|
|
class WanArbitration(unittest.TestCase):
|
|
"""Eight lost pings say the anchor went quiet; only both probes
|
|
failing say the internet did."""
|
|
|
|
def setUp(self):
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
self.store = Store(Path(self.dir.name) / "t.db")
|
|
self.notices = []
|
|
self.arb = WanEventArbiter(
|
|
self.store, lambda *a, **k: self.notices.append(a))
|
|
|
|
def tearDown(self):
|
|
self.store.close()
|
|
self.dir.cleanup()
|
|
|
|
def test_quiet_when_another_instrument_still_answers(self):
|
|
t = time.time()
|
|
self.arb.down(t, beyond_ok=True)
|
|
self.assertFalse(self.arb.real_outage)
|
|
self.assertEqual(self.notices, []) # the user's internet works
|
|
self.arb.up(t + 30)
|
|
evs = self.store.events()
|
|
self.assertEqual([e["kind"] for e in evs], ["icmp-quiet"])
|
|
self.assertIsNotNone(evs[0]["ended_ts"])
|
|
self.assertEqual(self.notices, [])
|
|
|
|
def test_outage_when_every_instrument_fails(self):
|
|
t = time.time()
|
|
self.arb.down(t, beyond_ok=False)
|
|
self.assertTrue(self.arb.real_outage)
|
|
# Logged at once; alarmed only once it has lasted — an outage the
|
|
# user could do nothing about should not interrupt them.
|
|
self.assertEqual(self.notices, [])
|
|
self.arb.tick(t + NOTIFY_AFTER_S + 0.1, beyond_ok=False)
|
|
self.assertEqual(len(self.notices), 1)
|
|
self.arb.up(t + 30)
|
|
self.assertEqual([e["kind"] for e in self.store.events()], ["outage"])
|
|
self.assertEqual(len(self.notices), 2) # down + recovered
|
|
|
|
def test_a_brief_outage_is_logged_and_never_alarms(self):
|
|
t = time.time()
|
|
self.arb.down(t, beyond_ok=False)
|
|
self.arb.tick(t + 1, beyond_ok=False)
|
|
self.arb.up(t + 2) # healed inside the window
|
|
self.assertEqual(self.notices, []) # neither alarm nor recovery
|
|
self.assertEqual([e["kind"] for e in self.store.events()], ["outage"])
|
|
|
|
def test_the_alarm_fires_once_not_every_tick(self):
|
|
t = time.time()
|
|
self.arb.down(t, beyond_ok=False)
|
|
for i in range(10):
|
|
self.arb.tick(t + NOTIFY_AFTER_S + i, beyond_ok=False)
|
|
self.assertEqual(len(self.notices), 1)
|
|
|
|
def test_escalates_one_way_when_the_rest_stop_too(self):
|
|
t = time.time()
|
|
self.arb.down(t, beyond_ok=True)
|
|
self.arb.tick(t + 2, beyond_ok=True) # still quiet, still no alarm
|
|
self.assertEqual(self.notices, [])
|
|
self.arb.tick(t + 5, beyond_ok=False) # now it is an outage
|
|
self.assertTrue(self.arb.real_outage)
|
|
self.assertEqual(self.notices, []) # still inside the delay
|
|
self.arb.tick(t + 5 + NOTIFY_AFTER_S + 0.1, beyond_ok=False)
|
|
self.assertEqual(len(self.notices), 1)
|
|
self.arb.up(t + 60)
|
|
evs = self.store.events()
|
|
self.assertEqual(sorted(e["kind"] for e in evs),
|
|
["icmp-quiet", "outage"])
|
|
for e in evs:
|
|
self.assertIsNotNone(e["ended_ts"])
|
|
|
|
def test_quiet_never_charges_reliability(self):
|
|
t = time.time()
|
|
self.arb.down(t, beyond_ok=True)
|
|
self.arb.up(t + 600)
|
|
self.assertEqual(self.store.outage_stats(3600, now=t + 700),
|
|
(0.0, 0, 0.0))
|
|
|
|
|
|
class LocalArbitration(unittest.TestCase):
|
|
"""A gateway that refuses pings is not a gateway that is unreachable.
|
|
|
|
This is the hotel case: every local ping lost, ROUTER UNREACHABLE on
|
|
screen, and the machine online the whole time.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.store = FakeStore()
|
|
self.notes = []
|
|
self.arb = LocalEventArbiter(
|
|
self.store, lambda *a, **k: self.notes.append(a))
|
|
|
|
def test_silent_gateway_with_traffic_crossing_it_is_not_an_outage(self):
|
|
self.arb.down(100.0, beyond_ok=True)
|
|
self.assertEqual(self.arb.kind, "gateway-quiet")
|
|
self.assertFalse(self.arb.real_outage)
|
|
kind, sev, leg, _ = self.store.opened[0]
|
|
self.assertEqual((kind, sev, leg), ("gateway-quiet", "warn", "local"))
|
|
# Warn-toned and unnotified: the user experienced nothing.
|
|
self.assertEqual(self.notes, [])
|
|
|
|
def test_nothing_answering_anywhere_is_a_real_outage(self):
|
|
self.arb.down(100.0, beyond_ok=False)
|
|
self.assertEqual(self.arb.kind, "outage")
|
|
self.assertTrue(self.arb.real_outage)
|
|
self.assertEqual(self.store.opened[0][0:3],
|
|
("outage", "critical", "local"))
|
|
# Logged at once, alarmed only after NOTIFY_AFTER_S.
|
|
self.assertEqual(self.notes, [])
|
|
self.arb.tick(100.0 + NOTIFY_AFTER_S + 0.1, beyond_ok=False)
|
|
self.assertEqual(len(self.notes), 1)
|
|
|
|
def test_escalation_is_one_way(self):
|
|
self.arb.down(100.0, beyond_ok=True)
|
|
# The far side goes quiet too: an outage starting mid-spell must alarm.
|
|
self.arb.tick(110.0, beyond_ok=False)
|
|
self.assertTrue(self.arb.real_outage)
|
|
self.assertEqual(self.notes, []) # inside the alarm delay
|
|
self.arb.tick(110.0 + NOTIFY_AFTER_S + 0.1, beyond_ok=False)
|
|
self.assertEqual(len(self.notes), 1)
|
|
# Nothing walks it back down again — flapping teaches people to
|
|
# ignore both verdicts.
|
|
self.arb.tick(140.0, beyond_ok=True)
|
|
self.assertTrue(self.arb.real_outage)
|
|
|
|
def test_recovery_only_notifies_for_a_real_outage(self):
|
|
self.arb.down(100.0, beyond_ok=True)
|
|
self.arb.up(130.0)
|
|
self.assertEqual(self.notes, [])
|
|
self.assertIsNone(self.arb.kind)
|
|
self.arb.down(200.0, beyond_ok=False)
|
|
self.arb.tick(200.0 + NOTIFY_AFTER_S + 0.1, beyond_ok=False)
|
|
self.arb.up(230.0)
|
|
self.assertEqual(len(self.notes), 2) # down + recovered
|
|
|
|
|
|
class CaptiveDetection(unittest.TestCase):
|
|
"""Replies prove a packet came back, not what sent it."""
|
|
|
|
def test_trace_verdicts(self):
|
|
self.assertEqual(trace_verdict("fl=1\nip=103.87.1.2\nts=2"), "open")
|
|
# A portal answering with its sign-in page.
|
|
self.assertEqual(trace_verdict("<html>Please sign in</html>"),
|
|
"intercepted")
|
|
self.assertEqual(trace_verdict("ip=not-an-address"), "intercepted")
|
|
# Nothing came back at all, which is a different thing.
|
|
self.assertEqual(trace_verdict(""), "silent")
|
|
self.assertEqual(trace_verdict(None), "silent")
|
|
|
|
def test_needs_both_halves_of_the_evidence(self):
|
|
c = CaptiveWatch.captive
|
|
# Replies but no proof of internet, confirmed: that is a portal.
|
|
self.assertTrue(c("intercepted", True, 2))
|
|
self.assertTrue(c("silent", True, 2))
|
|
# Proof of internet: never captive, however many strikes.
|
|
self.assertFalse(c("open", True, 9))
|
|
# Nothing answering at all is an outage, not a sign-in page — a
|
|
# portal prompt in front of a dead line would be worse than silence.
|
|
self.assertFalse(c("intercepted", False, 9))
|
|
# One failed fetch is a failed fetch.
|
|
self.assertFalse(c("intercepted", True, 1))
|
|
|
|
def test_confirms_on_the_second_check_and_clears_on_proof(self):
|
|
answers = ["intercepted", "intercepted", "open"]
|
|
calls = []
|
|
|
|
def check():
|
|
v = answers[min(len(calls), len(answers) - 1)]
|
|
calls.append(v)
|
|
return {"verdict": v, "proof": None}
|
|
|
|
w = CaptiveWatch(check, spawn=run_now)
|
|
w.tick(1000.0, probes_answering=True)
|
|
self.assertFalse(w.confirmed) # one strike
|
|
w.tick(1000.0 + 31, probes_answering=True)
|
|
self.assertTrue(w.confirmed)
|
|
self.assertTrue(w.snapshot()["captive"])
|
|
# Proof of the real internet retracts it immediately.
|
|
w.tick(1000.0 + 62, probes_answering=True)
|
|
self.assertFalse(w.confirmed)
|
|
self.assertEqual(w.verdict, "open")
|
|
|
|
def test_rate_limited_between_checks(self):
|
|
calls = []
|
|
|
|
def check():
|
|
calls.append(1)
|
|
return {"verdict": "intercepted", "proof": None}
|
|
|
|
w = CaptiveWatch(check, spawn=run_now)
|
|
w.tick(1000.0, True)
|
|
w.tick(1001.0, True)
|
|
w.tick(1029.0, True)
|
|
self.assertEqual(len(calls), 1) # 30 s floor holds
|
|
w.tick(1031.0, True)
|
|
self.assertEqual(len(calls), 2)
|
|
|
|
def test_suspicion_drops_when_nothing_answers(self):
|
|
w = CaptiveWatch(lambda: {"verdict": "intercepted", "proof": None},
|
|
spawn=run_now)
|
|
w.tick(1000.0, True)
|
|
w.tick(1031.0, True)
|
|
self.assertTrue(w.confirmed)
|
|
# The line goes down for real: hand it to the outage path.
|
|
w.tick(1062.0, probes_answering=False)
|
|
self.assertFalse(w.confirmed)
|
|
self.assertEqual(w.strikes, 0)
|
|
|
|
def test_publishes_nothing_before_it_knows_anything(self):
|
|
w = CaptiveWatch(lambda: {"verdict": "open", "proof": None},
|
|
spawn=run_now)
|
|
self.assertIsNone(w.snapshot())
|
|
|
|
|
|
class CaptiveCheckOffTheLoop(unittest.TestCase):
|
|
"""The reachability curl must never stall the 2 Hz loop, and it runs on
|
|
suspicion rather than on a clock. Until 0.2.13 it did both wrong."""
|
|
|
|
OPEN = {"verdict": "open", "proof": {"ip": "203.0.113.9", "family": "v4"}}
|
|
|
|
def test_tick_returns_before_a_slow_check_finishes(self):
|
|
import threading
|
|
release, done = threading.Event(), threading.Event()
|
|
|
|
def slow_check():
|
|
release.wait(5)
|
|
done.set()
|
|
return dict(self.OPEN)
|
|
|
|
w = CaptiveWatch(slow_check) # the real thread spawn
|
|
t0 = time.monotonic()
|
|
w.tick(1000.0, True)
|
|
self.assertLess(time.monotonic() - t0, 0.5)
|
|
self.assertEqual(w.verdict, "unknown") # nothing has landed yet
|
|
release.set()
|
|
self.assertTrue(done.wait(5))
|
|
for _ in range(100): # a later tick collects it
|
|
w.tick(1000.5, True)
|
|
if w.verdict != "unknown":
|
|
break
|
|
time.sleep(0.01)
|
|
self.assertEqual(w.verdict, "open")
|
|
self.assertEqual(w.proof["ip"], "203.0.113.9")
|
|
|
|
def test_proof_of_internet_backs_off_to_hourly(self):
|
|
calls = []
|
|
|
|
def check():
|
|
calls.append(1)
|
|
return dict(self.OPEN)
|
|
|
|
w = CaptiveWatch(check, spawn=run_now)
|
|
w.tick(1000.0, True)
|
|
for t in (1031.0, 1600.0, 4599.0):
|
|
w.tick(t, True)
|
|
self.assertEqual(len(calls), 1) # not the old 30 s clock
|
|
w.tick(1000.0 + CaptiveWatch.RECHECK_OPEN_S, True)
|
|
self.assertEqual(len(calls), 2)
|
|
|
|
def test_request_starts_over_and_drops_a_stale_answer(self):
|
|
held = []
|
|
w = CaptiveWatch(lambda: dict(self.OPEN), spawn=held.append)
|
|
w.tick(1000.0, True) # a check for network A
|
|
self.assertEqual(len(held), 1)
|
|
w.request() # the route changed
|
|
self.assertEqual(w.verdict, "unknown")
|
|
held[0]() # A's answer arrives late
|
|
w.tick(1001.0, True)
|
|
self.assertIsNone(w.proof) # dropped, not adopted
|
|
self.assertEqual(len(held), 2) # and B is being asked
|
|
held[1]()
|
|
w.tick(1002.0, True)
|
|
self.assertEqual(w.verdict, "open")
|
|
|
|
def test_daemon_adopts_the_address_from_the_proof_once(self):
|
|
import os as _os
|
|
from nexthopd.daemon import Daemon
|
|
with tempfile.TemporaryDirectory() as d:
|
|
_os.environ["XDG_STATE_HOME"] = d
|
|
try:
|
|
dm = Daemon()
|
|
try:
|
|
dm.captive.proof = dict(self.OPEN["proof"])
|
|
dm.captive.checked_ts = 1234
|
|
dm.adopt_wan_ip()
|
|
self.assertEqual(dm.wan_ip,
|
|
{"ip": "203.0.113.9", "family": "v4",
|
|
"checked_ts": 1234})
|
|
# A later check that could not prove the internet
|
|
# leaves the last answer standing.
|
|
dm.captive.proof = None
|
|
dm.adopt_wan_ip()
|
|
self.assertEqual(dm.wan_ip["ip"], "203.0.113.9")
|
|
finally:
|
|
dm.store.close()
|
|
finally:
|
|
del _os.environ["XDG_STATE_HOME"]
|
|
|
|
|
|
class RouteChangeKeepsHistoryThroughAnOutage(unittest.TestCase):
|
|
"""Losing the route must not discard the window that explains why."""
|
|
|
|
def test_no_gateway_is_an_outage_not_a_new_network(self):
|
|
import types
|
|
from nexthopd import daemon as dmod
|
|
|
|
calls = {"reset": 0}
|
|
d = types.SimpleNamespace(
|
|
config={"internetAnchor": "1.1.1.1"},
|
|
route={"gateway": "192.168.1.1", "iface": "wlan0"},
|
|
probes=[],
|
|
)
|
|
|
|
def fake_route_to(anchor):
|
|
return fake_route_to.answer
|
|
|
|
original = dmod.net.route_to
|
|
dmod.net.route_to = fake_route_to
|
|
try:
|
|
# Bind the real method to our stand-in object and count resets
|
|
# by watching for the attribute the reset path writes first.
|
|
def start_probes():
|
|
calls["reset"] += 1
|
|
d.start_probes = start_probes
|
|
d._new_instrument_series = lambda: None
|
|
d.counter_samples = []
|
|
d.wan_ip = "x"
|
|
d._wan_ip_at = 1.0
|
|
d._instrument_probes = {}
|
|
d.local = object()
|
|
d.probes = []
|
|
# The reset path also asks the reachability check to start over.
|
|
d.captive = type("Captive", (), {"request": lambda self: None})()
|
|
d._rebuild_probes = lambda fresh: dmod.Daemon._rebuild_probes(d, fresh)
|
|
|
|
# The route vanishes: no reset, history kept, probes untouched.
|
|
fake_route_to.answer = {}
|
|
dmod.Daemon.restart_probes_if_route_changed(d)
|
|
self.assertEqual(calls["reset"], 0)
|
|
self.assertEqual(d.route, {"gateway": "192.168.1.1",
|
|
"iface": "wlan0"})
|
|
self.assertEqual(d.wan_ip, "x")
|
|
|
|
# It comes back on the same network: still no reset.
|
|
fake_route_to.answer = {"gateway": "192.168.1.1",
|
|
"iface": "wlan0"}
|
|
dmod.Daemon.restart_probes_if_route_changed(d)
|
|
self.assertEqual(calls["reset"], 0)
|
|
|
|
# A genuinely different network does reset, exactly once.
|
|
fake_route_to.answer = {"gateway": "10.0.0.1", "iface": "wlan0"}
|
|
dmod.Daemon.restart_probes_if_route_changed(d)
|
|
self.assertEqual(calls["reset"], 1)
|
|
self.assertIsNone(d.wan_ip)
|
|
finally:
|
|
dmod.net.route_to = original
|
|
|
|
|
|
class DisruptionProducer(unittest.TestCase):
|
|
"""A run of losses that recovers before it becomes an outage.
|
|
|
|
Until now nothing opened a `disruption` event, so half the reliability
|
|
formula — a half-weight on duration, a 300 s recovery charge, a 25-point
|
|
cap, all designed against real history in 0.1.10 — could never fire, and
|
|
a blip the user felt cost the score nothing.
|
|
"""
|
|
|
|
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 run_losses(self, n, cadence=0.5, t0=None):
|
|
"""`n` lost samples at `cadence`, then a reply, with a watch tick at
|
|
each sample — which is how the default 500 ms cadence behaves."""
|
|
t0 = time.time() if t0 is None else t0
|
|
w = LegWatch()
|
|
moves = []
|
|
for i in range(n):
|
|
t = t0 + i * cadence
|
|
moves.append(w.sample(LegState(False, t, t0, i + 1), t))
|
|
t = t0 + n * cadence
|
|
moves.append(w.sample(LegState(True, t, None, 0), t))
|
|
return w, moves
|
|
|
|
def test_a_single_loss_is_noise(self):
|
|
w, moves = self.run_losses(1)
|
|
self.assertEqual(moves, [None, None])
|
|
self.assertIsNone(w.blip)
|
|
|
|
def test_below_the_threshold_stays_silent(self):
|
|
w, moves = self.run_losses(2)
|
|
self.assertIsNone(moves[-1])
|
|
self.assertIsNone(w.blip)
|
|
|
|
def test_three_losses_that_recover_are_a_disruption(self):
|
|
t0 = time.time()
|
|
w, moves = self.run_losses(3, t0=t0)
|
|
self.assertEqual(moves[-1], "disruption")
|
|
began, ended = w.blip
|
|
# Timed from the FIRST loss, not from the threshold being crossed:
|
|
# the interruption started when the packets started going missing.
|
|
self.assertEqual(began, t0)
|
|
# 1.5 s of silence on the stream itself: three lost probes at the
|
|
# default 500 ms, exactly DISRUPTION_AFTER_S. Until 0.2.15 a "loss"
|
|
# was an empty 3 s window, and this same blip could not register.
|
|
self.assertEqual(ended, t0 + DISRUPTION_AFTER_S)
|
|
|
|
def test_reaching_the_outage_threshold_is_an_outage_not_a_disruption(self):
|
|
# Nine lost probes at 500 ms: the ninth lands 4.0 s after the first,
|
|
# which is OUTAGE_AFTER_S of unbroken silence.
|
|
w, moves = self.run_losses(9)
|
|
self.assertEqual(moves[8], "down")
|
|
self.assertEqual(moves[-1], "up")
|
|
self.assertIsNone(w.blip)
|
|
|
|
def test_a_run_recovering_just_short_of_the_threshold_is_a_disruption(self):
|
|
w, moves = self.run_losses(8) # 3.5 s of silence, then a reply
|
|
self.assertNotIn("down", moves)
|
|
self.assertEqual(moves[-1], "disruption")
|
|
|
|
def test_thresholds_are_seconds_not_ticks(self):
|
|
# The same 1.5 s blip seen by a loop ticking five times as often
|
|
# must read the same: the stream carries the time, not the tick.
|
|
t0 = 1000.0
|
|
w = LegWatch()
|
|
for k in range(15):
|
|
t = t0 + k * 0.1
|
|
w.sample(LegState(False, t, t0, 1 + k // 5), t)
|
|
self.assertEqual(w.sample(LegState(True, t0 + 1.5, None, 0), t0 + 1.5),
|
|
"disruption")
|
|
|
|
def test_the_outage_starts_when_the_packets_stopped(self):
|
|
t0 = 1000.0
|
|
w, _ = self.run_losses(9, t0=t0)
|
|
w2 = LegWatch()
|
|
for i in range(9):
|
|
t = t0 + i * 0.5
|
|
move = w2.sample(LegState(False, t, t0, i + 1), t)
|
|
self.assertEqual(move, "down")
|
|
self.assertEqual(w2.down_since, t0) # not the tick that noticed
|
|
|
|
def test_one_lost_probe_is_never_an_event_at_any_cadence(self):
|
|
# At a 5 s probe interval a single loss is followed by 5 s with no
|
|
# sample at all. Measured in seconds alone that would be an outage;
|
|
# two lost probes are the floor.
|
|
t0 = 1000.0
|
|
w = LegWatch()
|
|
self.assertIsNone(w.sample(LegState(False, t0, t0, 1), t0 + 4.5))
|
|
self.assertIsNone(w.down_since)
|
|
self.assertEqual(w.sample(LegState(False, t0 + 5.0, t0, 2), t0 + 5.0),
|
|
"down")
|
|
|
|
def test_recorded_closed_with_a_duration_and_charged(self):
|
|
w, _ = self.run_losses(4)
|
|
d = _FakeDaemonForDisruption(self.store)
|
|
d.record_disruption("local", w, beyond_ok=False)
|
|
evs = self.store.events()
|
|
self.assertEqual([e["kind"] for e in evs], ["disruption"])
|
|
self.assertIsNotNone(evs[0]["ended_ts"])
|
|
# And it now actually reaches Reliability, which was the point.
|
|
frac, count, disrupted = self.store.outage_stats(
|
|
3600, now=time.time() + 10)
|
|
self.assertEqual(count, 1)
|
|
self.assertGreater(disrupted, 0.0)
|
|
self.assertEqual(frac, 0.0) # a blip is not downtime
|
|
self.assertLess(score.reliability(frac, count,
|
|
disruption_fraction=disrupted),
|
|
100.0)
|
|
|
|
def test_arbitrated_like_an_outage(self):
|
|
w, _ = self.run_losses(4)
|
|
d = _FakeDaemonForDisruption(self.store)
|
|
# Something past this leg kept answering, so the leg interrupted
|
|
# nothing — a gateway dropping pings while traffic crosses it.
|
|
d.record_disruption("local", w, beyond_ok=True)
|
|
self.assertEqual(self.store.events(), [])
|
|
self.assertIsNone(w.blip) # consumed either way
|
|
|
|
def test_never_stored_with_a_zero_span(self):
|
|
# outage_stats drops any row whose end is not after its start, so a
|
|
# blip must never round away to nothing.
|
|
base = time.time()
|
|
w = LegWatch()
|
|
w.blip = (base + 0.4, base + 0.6)
|
|
d = _FakeDaemonForDisruption(self.store)
|
|
d.record_disruption("wan", w, beyond_ok=False)
|
|
e = self.store.events()[0]
|
|
self.assertGreater(e["ended_ts"], e["ts"])
|
|
_, count, disrupted = self.store.outage_stats(3600, now=base + 10)
|
|
self.assertEqual(count, 1)
|
|
self.assertGreater(disrupted, 0.0)
|
|
|
|
|
|
class InflationPlausibility(unittest.TestCase):
|
|
"""Queueing can only add delay, so a ratio below 1 is not a reading."""
|
|
|
|
@staticmethod
|
|
def ratio(loaded, idle):
|
|
"""The published value for a given pair, with the floor applied."""
|
|
r = loaded / idle
|
|
if r < MIN_PLAUSIBLE_INFLATION:
|
|
return None
|
|
return round(max(1.0, r), 2)
|
|
|
|
def test_the_case_seen_live_is_withheld(self):
|
|
# 0.87 was published on 716 samples per side: the sample floor
|
|
# cannot catch a wrong-direction result, only a thin one.
|
|
self.assertIsNone(self.ratio(20.3, 23.4))
|
|
|
|
def test_indistinguishable_reads_as_no_inflation(self):
|
|
# Within noise of 1, the honest statement is "no inflation", not
|
|
# "faster under load".
|
|
self.assertEqual(self.ratio(9.8, 10.0), 1.0)
|
|
self.assertEqual(self.ratio(10.0, 10.0), 1.0)
|
|
|
|
def test_real_inflation_still_reported(self):
|
|
self.assertEqual(self.ratio(13.0, 10.0), 1.3)
|
|
# A badly queued link is not implausible, however large.
|
|
self.assertEqual(self.ratio(250.0, 10.0), 25.0)
|
|
|
|
|
|
class SettingsApplyLive(unittest.TestCase):
|
|
"""The anchor and the probe interval used to be read only when probes
|
|
were built, while the Setup tab said settings apply without a restart."""
|
|
|
|
def setUp(self):
|
|
from nexthopd.daemon import Daemon
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
os.environ["XDG_STATE_HOME"] = self.dir.name
|
|
self.d = Daemon()
|
|
self.calls = []
|
|
self.d._rebuild_probes = lambda route: self.calls.append(("rebuild", route))
|
|
self.d._apply_seats = lambda changes: self.calls.append(("seats", changes))
|
|
|
|
def tearDown(self):
|
|
self.d.store.close()
|
|
del os.environ["XDG_STATE_HOME"]
|
|
self.dir.cleanup()
|
|
|
|
def test_nothing_happens_before_probes_exist(self):
|
|
self.d.config.values["probeIntervalMs"] = 1000
|
|
self.d.restart_probes_if_settings_changed()
|
|
self.assertEqual(self.calls, [])
|
|
|
|
def test_interval_change_moves_the_cadence_in_place(self):
|
|
class Probe:
|
|
interval = None
|
|
|
|
def set_interval(self, v):
|
|
self.interval = v
|
|
|
|
anchor = self.d.config["internetAnchor"]
|
|
self.d._probe_settings = (anchor, 500)
|
|
self.d._local_probe = Probe()
|
|
self.d.config.values["probeIntervalMs"] = 1000
|
|
self.d.restart_probes_if_settings_changed()
|
|
self.assertEqual(self.d._local_probe.interval, 1.0)
|
|
self.assertEqual([c[0] for c in self.calls], ["seats"])
|
|
self.assertEqual(self.d._probe_settings, (anchor, 1000))
|
|
# Applied once, not on every tick.
|
|
self.d.restart_probes_if_settings_changed()
|
|
self.assertEqual(len(self.calls), 1)
|
|
|
|
def test_anchor_change_rebuilds_the_probes(self):
|
|
import nexthopd.daemon as dmod
|
|
self.d._probe_settings = (self.d.config["internetAnchor"], 500)
|
|
real = dmod.net.route_to
|
|
dmod.net.route_to = lambda a: {"gateway": "192.0.2.1", "iface": "x"}
|
|
try:
|
|
self.d.config.values["internetAnchor"] = "9.9.9.9"
|
|
self.d.restart_probes_if_settings_changed()
|
|
finally:
|
|
dmod.net.route_to = real
|
|
self.assertEqual(self.calls, [("rebuild", {"gateway": "192.0.2.1",
|
|
"iface": "x"})])
|
|
|
|
|
|
class LegStateReading(unittest.TestCase):
|
|
"""leg_state() turns a probe stream into what the outage watch consumes:
|
|
is the newest sample a reply, and if not, since when has it been quiet."""
|
|
|
|
def test_empty_and_stale_streams_are_unknown_not_outages(self):
|
|
now = 1000.0
|
|
self.assertIsNone(leg_state([], now))
|
|
# The probe stopped talking: ping -O keeps emitting losses through
|
|
# a real outage, so a silent stream is a dead probe, not a dead line.
|
|
self.assertIsNone(leg_state([(now - LEG_STALE_S - 1, None, False)], now))
|
|
|
|
def test_a_reply_at_the_head_is_ok(self):
|
|
now = 1000.0
|
|
st = leg_state([(now - 1.0, None, False), (now - 0.5, 8.0, False)], now)
|
|
self.assertTrue(st.ok)
|
|
self.assertEqual(st.lost, 0)
|
|
self.assertIsNone(st.run_since)
|
|
|
|
def test_the_trailing_run_is_measured_from_its_first_loss(self):
|
|
now = 1000.0
|
|
samples = [(now - 3.0, 7.0, False), (now - 2.5, None, False),
|
|
(now - 2.0, None, False), (now - 1.5, None, False),
|
|
(now - 1.0, None, False)]
|
|
st = leg_state(samples, now)
|
|
self.assertFalse(st.ok)
|
|
self.assertEqual(st.run_since, now - 2.5)
|
|
self.assertEqual(st.lost, 4)
|
|
self.assertEqual(st.ts, now - 1.0)
|
|
|
|
def test_a_merged_stream_ends_the_run_on_any_instruments_reply(self):
|
|
# ICMP at 2 Hz losing everything while the TCP instrument answers:
|
|
# the newest sample decides, whichever instrument produced it.
|
|
now = 1000.0
|
|
merged = sorted([(now - 1.5, None, False), (now - 1.0, None, False),
|
|
(now - 0.5, None, False), # icmp
|
|
(now - 0.2, 21.0, False)], # tcp, replied
|
|
key=lambda s: s[0])
|
|
self.assertTrue(leg_state(merged, now).ok)
|
|
|
|
def test_a_two_second_blip_is_now_seen_end_to_end(self):
|
|
# The case the old any-reply window could not register at all.
|
|
t0 = 1000.0
|
|
w = LegWatch()
|
|
stream = [(t0 - 0.5, 6.0, False)]
|
|
move = None
|
|
for k in range(4): # 4 losses over 1.5 s
|
|
stream.append((t0 + k * 0.5, None, False))
|
|
move = w.sample(leg_state(stream, t0 + k * 0.5), t0 + k * 0.5)
|
|
self.assertIsNone(move)
|
|
stream.append((t0 + 2.0, 6.5, False))
|
|
move = w.sample(leg_state(stream, t0 + 2.0), t0 + 2.0)
|
|
self.assertEqual(move, "disruption")
|
|
self.assertEqual(w.blip, (t0, t0 + 2.0))
|
|
|
|
def test_outage_rows_start_when_the_silence_began(self):
|
|
# The arbiters take the run's start, so the stored row carries the
|
|
# true onset rather than the tick that crossed the threshold.
|
|
with tempfile.TemporaryDirectory() as d:
|
|
store = Store(Path(d) / "t.db")
|
|
try:
|
|
now = time.time()
|
|
WanEventArbiter(store, lambda *a, **k: None).down(
|
|
now, False, since=now - OUTAGE_AFTER_S)
|
|
LocalEventArbiter(store, lambda *a, **k: None).down(
|
|
now, True, since=now - OUTAGE_AFTER_S)
|
|
rows = {e["kind"]: e["ts"] for e in store.events()}
|
|
self.assertEqual(rows["outage"], int(now - OUTAGE_AFTER_S))
|
|
self.assertEqual(rows["gateway-quiet"], int(now - OUTAGE_AFTER_S))
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
class ContentCheckReadiness(unittest.TestCase):
|
|
"""A link that has just associated is not the line yet.
|
|
|
|
The case this is built from: a check 55 s after associating read
|
|
63 Mbps on a 380 Mbps line, taken on 2.4 GHz at a 16 Mbps tx rate,
|
|
nine minutes before the link moved itself to 5 GHz.
|
|
"""
|
|
|
|
def test_a_fresh_association_waits(self):
|
|
now = 1000.0
|
|
self.assertFalse(check_ready(now, now - 5, None, None))
|
|
self.assertFalse(check_ready(now, now - CHECK_SETTLE_S + 1, None, None))
|
|
|
|
def test_a_settled_association_is_ready(self):
|
|
now = 1000.0
|
|
self.assertTrue(check_ready(now, now - CHECK_SETTLE_S, None, None))
|
|
|
|
def test_a_link_with_its_rate_down_waits(self):
|
|
# The same signal that opens a rate-drop event.
|
|
now = 1000.0
|
|
self.assertFalse(check_ready(now, now - 600, now - 3, None))
|
|
|
|
def test_a_wired_link_is_always_ready(self):
|
|
# No association and no rate tracking: nothing to wait for.
|
|
self.assertTrue(check_ready(1000.0, None, None, None))
|
|
|
|
def test_the_deferral_is_capped_so_a_slow_link_still_scores(self):
|
|
now = 1000.0
|
|
waiting = now - CHECK_DEFER_MAX_S
|
|
# Still associating, still rate-limited, but we have waited long
|
|
# enough: an honest low number beats no number for ever.
|
|
self.assertTrue(check_ready(now, now - 1, now - 1, waiting))
|
|
self.assertFalse(check_ready(now, now - 1, now - 1, now - 5))
|
|
|
|
|
|
class SpeedTrustEndToEnd(unittest.TestCase):
|
|
"""The same thing through speed_score, where the samples come from the
|
|
store and the peak has to be matched to this network."""
|
|
|
|
NET = "x3me"
|
|
|
|
def setUp(self):
|
|
from nexthopd.daemon import Daemon
|
|
self.dir = tempfile.TemporaryDirectory()
|
|
os.environ["XDG_STATE_HOME"] = self.dir.name
|
|
self.d = Daemon()
|
|
self.now = time.time()
|
|
|
|
def tearDown(self):
|
|
self.d.store.close()
|
|
del os.environ["XDG_STATE_HOME"]
|
|
self.dir.cleanup()
|
|
|
|
def content(self, down, ago, network=None):
|
|
self.d.store.put_test(int(self.now - ago), "content", "cloudflare",
|
|
down_mbps=down, up_mbps=10.0, ok=True,
|
|
network=self.NET if network is None else network)
|
|
|
|
def peak(self, down, ago, network=None):
|
|
self.d.store.put_test(int(self.now - ago), "peak", "cloudflare",
|
|
down_mbps=down, up_mbps=50.0, ok=True,
|
|
network=self.NET if network is None else network)
|
|
|
|
def test_the_arrival_check_alone_is_reported_but_not_counted(self):
|
|
self.content(63.4, 300)
|
|
spd, ctx = self.d.speed_score(self.now, self.NET)
|
|
self.assertIsNotNone(spd) # still shown
|
|
self.assertEqual(ctx["samples"], 1)
|
|
self.assertFalse(ctx["scored"]) # but not the headline
|
|
|
|
def test_a_second_check_makes_it_count(self):
|
|
self.content(63.4, 300)
|
|
self.content(380.0, 60)
|
|
spd, ctx = self.d.speed_score(self.now, self.NET)
|
|
self.assertTrue(ctx["scored"])
|
|
# Median of two takes the higher, so one clean check recovers it.
|
|
self.assertEqual(ctx["last_down"], 380.0)
|
|
|
|
def test_a_fresh_peak_here_withdraws_a_contradicted_figure(self):
|
|
self.content(63.4, 900)
|
|
self.content(70.0, 300)
|
|
self.peak(251.4, 120)
|
|
spd, ctx = self.d.speed_score(self.now, self.NET)
|
|
self.assertEqual(ctx["peak_down"], 251.4)
|
|
self.assertFalse(ctx["scored"])
|
|
|
|
def test_a_peak_from_another_network_says_nothing_about_this_one(self):
|
|
self.content(63.4, 900)
|
|
self.content(70.0, 300)
|
|
self.peak(251.4, 120, network="SomeHotel")
|
|
_, ctx = self.d.speed_score(self.now, self.NET)
|
|
self.assertIsNone(ctx["peak_down"])
|
|
self.assertTrue(ctx["scored"])
|
|
|
|
def test_a_stale_peak_no_longer_speaks_for_the_line(self):
|
|
self.content(63.4, 900)
|
|
self.content(70.0, 300)
|
|
self.peak(251.4, PEAK_FRESH_S + 60)
|
|
_, ctx = self.d.speed_score(self.now, self.NET)
|
|
self.assertIsNone(ctx["peak_down"])
|
|
self.assertTrue(ctx["scored"])
|
|
|
|
|
|
class AnchorSetting(unittest.TestCase):
|
|
def test_a_leading_dash_is_not_an_anchor(self):
|
|
check = Config.SCHEMA["internetAnchor"][1]
|
|
for bad in ("-1.1.1.1", "--help", "-", "-x.example", ".x", ""):
|
|
self.assertIsNone(check(bad), bad)
|
|
for ok in ("1.1.1.1", "::1", "2606:4700:4700::1111", "dns.google", "a"):
|
|
self.assertEqual(check(ok), ok)
|
|
|
|
|
|
class BandwidthTestsTakeTurns(unittest.TestCase):
|
|
def test_one_test_at_a_time(self):
|
|
import types
|
|
from nexthopd.daemon import Daemon
|
|
|
|
def idle(peak, content):
|
|
return Daemon.tests_idle(types.SimpleNamespace(
|
|
peak_running=peak, content_running=content))
|
|
|
|
self.assertTrue(idle(False, False))
|
|
self.assertFalse(idle(True, False))
|
|
self.assertFalse(idle(False, True))
|
|
|
|
|
|
class LinkOffTheLoop(unittest.TestCase):
|
|
"""The local-end snapshot is three subprocesses with 2 s timeouts each;
|
|
the loop reads the collector's latest dict and never waits for them."""
|
|
|
|
def test_reading_never_waits_for_a_slow_snapshot(self):
|
|
import threading
|
|
from nexthopd.daemon import LinkCollector
|
|
calls = []
|
|
gate = threading.Event()
|
|
|
|
def slow(anchor):
|
|
calls.append(anchor)
|
|
if len(calls) > 1:
|
|
gate.wait(5.0) # the second read hangs like a wedged iw
|
|
return {"iface": "wlo1", "kind": "wifi", "n": len(calls),
|
|
"station": {"tx_retries": 1}}
|
|
|
|
c = LinkCollector(lambda: "1.1.1.1", snapshot_fn=slow, interval_s=0.01)
|
|
c.start()
|
|
self.addCleanup(c.stop)
|
|
self.addCleanup(gate.set)
|
|
# start() took the first snapshot itself, so there is one to read...
|
|
self.assertEqual(c.latest["n"], 1)
|
|
# ...and reading while the thread is stuck costs nothing.
|
|
deadline = time.monotonic() + 2.0
|
|
while len(calls) < 2 and time.monotonic() < deadline:
|
|
time.sleep(0.005)
|
|
t0 = time.monotonic()
|
|
snap = c.latest
|
|
self.assertLess(time.monotonic() - t0, 0.05)
|
|
self.assertEqual(snap["n"], 1)
|
|
# A copy: annotating it does not reach the collector's own dict.
|
|
snap["station"]["retry_pct"] = 50.0
|
|
self.assertNotIn("retry_pct", c.latest["station"])
|
|
|
|
def test_a_failing_snapshot_keeps_the_last_good_one(self):
|
|
from nexthopd.daemon import LinkCollector
|
|
state = {"n": 0}
|
|
|
|
def flaky(anchor):
|
|
state["n"] += 1
|
|
if state["n"] > 1:
|
|
raise OSError("iw went away")
|
|
return {"iface": "wlo1", "n": 1}
|
|
|
|
c = LinkCollector(lambda: "x", snapshot_fn=flaky, interval_s=0.01)
|
|
c.start()
|
|
self.addCleanup(c.stop)
|
|
deadline = time.monotonic() + 2.0
|
|
while state["n"] < 3 and time.monotonic() < deadline:
|
|
time.sleep(0.005)
|
|
self.assertGreaterEqual(state["n"], 3)
|
|
self.assertEqual(c.latest, {"iface": "wlo1", "n": 1})
|
|
self.assertTrue(c.is_alive())
|
|
|
|
|
|
class SnapshotFreshnessBudget(unittest.TestCase):
|
|
"""Nothing on the loop may be budgeted for longer than the snapshot it
|
|
delays is allowed to be old.
|
|
|
|
The loop writes live.json and then does work. `AppTraffic.poll()` is
|
|
the only blocking call left in that stretch, so its deadline is how
|
|
stale live.json can get — and past `BarWidget.staleAfterS` the bar
|
|
stops believing the daemon: glyph only, no index, no headline, and
|
|
since 0.2.39 no liveness ring on the path sparklines. Both numbers
|
|
were 5: one file's worst case was exactly the other file's failure
|
|
threshold, so a single slow `ss` could report a perfectly healthy
|
|
daemon as absent.
|
|
|
|
Pin the relationship, not either number — they live in different
|
|
files and in different languages, and neither one is wrong alone.
|
|
"""
|
|
|
|
def stale_after_s(self):
|
|
import re
|
|
src = (REPO / "BarWidget.qml").read_text()
|
|
m = re.search(r"property\s+int\s+staleAfterS\s*:\s*(\d+)", src)
|
|
self.assertIsNotNone(
|
|
m, "staleAfterS is gone from BarWidget — find where the "
|
|
"staleness contract moved and re-point this test")
|
|
return float(m.group(1))
|
|
|
|
def test_the_blocking_poll_fits_well_inside_the_staleness_contract(self):
|
|
from nexthopd.apps import AppTraffic
|
|
contract = self.stale_after_s()
|
|
self.assertLess(
|
|
AppTraffic.POLL_DEADLINE_S, contract / 2.0,
|
|
"a full-deadline ss read would age live.json into the "
|
|
"bar's no-data state on a healthy daemon")
|
|
|
|
def test_the_poll_deadline_covers_the_reap_too(self):
|
|
"""The budget is the call's, not the read's — see `_reap`."""
|
|
from nexthopd.apps import REAP_RESERVE_S, AppTraffic
|
|
self.assertLess(REAP_RESERVE_S, AppTraffic.POLL_DEADLINE_S)
|
|
|
|
|
|
class LiveJsonContract(unittest.TestCase):
|
|
"""Every key the QML reads off live.json must be one compose_live
|
|
publishes. The dict literal is the contract; this pins the two halves
|
|
to each other, so a renamed or dropped key fails here rather than as a
|
|
blank in the panel."""
|
|
|
|
@staticmethod
|
|
def published():
|
|
import ast
|
|
src = (REPO / "nexthopd" / "daemon.py").read_text()
|
|
fn = next(n for n in ast.walk(ast.parse(src))
|
|
if isinstance(n, ast.FunctionDef) and n.name == "compose_live")
|
|
ret = [n for n in ast.walk(fn)
|
|
if isinstance(n, ast.Return) and isinstance(n.value, ast.Dict)][-1]
|
|
keys, nested = set(), {}
|
|
for k, v in zip(ret.value.keys, ret.value.values):
|
|
if isinstance(k, ast.Constant):
|
|
keys.add(k.value)
|
|
if isinstance(v, ast.Dict):
|
|
nested[k.value] = {kk.value for kk in v.keys
|
|
if isinstance(kk, ast.Constant)}
|
|
return keys, nested
|
|
|
|
@staticmethod
|
|
def read_by_qml():
|
|
import re
|
|
reads, nested = set(), {}
|
|
for q in REPO.glob("*.qml"):
|
|
for m in re.finditer(r"\blive\.([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?",
|
|
q.read_text()):
|
|
if m.group(1) == "json": # the file's name, in prose
|
|
continue
|
|
reads.add(m.group(1))
|
|
if m.group(2):
|
|
nested.setdefault(m.group(1), set()).add(m.group(2))
|
|
return reads, nested
|
|
|
|
def test_qml_reads_only_what_the_daemon_publishes(self):
|
|
keys, nested_keys = self.published()
|
|
reads, nested_reads = self.read_by_qml()
|
|
self.assertTrue(reads, "the scan found nothing — the regex is broken")
|
|
self.assertEqual(reads - keys, set())
|
|
for parent, fields in nested_reads.items():
|
|
if parent in nested_keys:
|
|
self.assertEqual(fields - nested_keys[parent], set(), parent)
|
|
|
|
def test_the_keys_the_shell_service_relies_on_are_published(self):
|
|
keys, _ = self.published()
|
|
# The version handover and the liveness watch cannot work without these.
|
|
self.assertTrue({"t", "pid", "pid_start", "daemon_version", "state"} <= keys)
|
|
|
|
|
|
class ContentHint(unittest.TestCase):
|
|
"""What the daemon hands the check to size itself with."""
|
|
|
|
class Store:
|
|
def __init__(self, rows):
|
|
self.rows = rows
|
|
|
|
def tests(self, limit=20, kind=None):
|
|
return self.rows[:limit]
|
|
|
|
def hint(self, rows, network):
|
|
d = Daemon.__new__(Daemon)
|
|
d.store = self.Store(rows)
|
|
return Daemon._content_hint(d, network)
|
|
|
|
def row(self, network, down, up, ok=True):
|
|
return {"ok": ok, "network": network, "down_mbps": down, "up_mbps": up}
|
|
|
|
def test_nothing_stored_gives_no_hint(self):
|
|
self.assertEqual(self.hint([], "home"), (None, None))
|
|
|
|
def test_only_this_network_counts(self):
|
|
rows = [self.row("cafe", 900.0, 400.0), self.row("home", 90.0, 20.0)]
|
|
self.assertEqual(self.hint(rows, "home"), (90.0, 20.0))
|
|
|
|
def test_the_best_recent_reading_wins_not_the_last(self):
|
|
# A check that came in low must not shrink the next transfer, which
|
|
# would read lower again: sizing off the last reading is a ratchet.
|
|
rows = [self.row("home", 40.0, 5.0),
|
|
self.row("home", 380.0, 90.0),
|
|
self.row("home", 350.0, 88.0)]
|
|
self.assertEqual(self.hint(rows, "home"), (380.0, 90.0))
|
|
|
|
def test_failed_and_empty_readings_are_skipped(self):
|
|
rows = [self.row("home", 900.0, 400.0, ok=False),
|
|
self.row("home", None, None),
|
|
self.row("home", 120.0, 30.0)]
|
|
self.assertEqual(self.hint(rows, "home"), (120.0, 30.0))
|
|
|
|
def test_a_direction_with_no_readings_stays_none(self):
|
|
rows = [self.row("home", 120.0, None)]
|
|
self.assertEqual(self.hint(rows, "home"), (120.0, None))
|
|
|
|
|
|
class LoadFloor(unittest.TestCase):
|
|
"""What counts as a busy link, for the loaded/idle latency split.
|
|
|
|
The split used to borrow the Wi-Fi power-save floor of 25 kB/s, which on
|
|
this laptop sat inside the idle distribution: median minute 18 kB/s, p75
|
|
42 kB/s, and 36.6% of all minutes tagged loaded. Across 8,971 stored
|
|
minutes the loaded half read FASTER than the idle half 57% of the time —
|
|
a link cannot answer faster while busy, and a coin flip is what two
|
|
buckets holding the same thing look like.
|
|
"""
|
|
|
|
class Link:
|
|
latest = {"ssid": "home"}
|
|
|
|
class Store:
|
|
def __init__(self, mbps):
|
|
self.mbps = mbps
|
|
self.calls = 0
|
|
|
|
def baseline_speed(self, **kw):
|
|
self.calls += 1
|
|
return self.mbps
|
|
|
|
def daemon(self, mbps):
|
|
d = Daemon.__new__(Daemon)
|
|
d.link = self.Link()
|
|
d.store = self.Store(mbps)
|
|
return d
|
|
|
|
def test_the_floor_is_a_tenth_of_what_the_line_carries(self):
|
|
d = self.daemon(400.0) # 400 Mbps = 50 MB/s
|
|
self.assertAlmostEqual(d.load_floor_bps(1000.0), 5_000_000.0, delta=1)
|
|
|
|
def test_a_slow_line_is_not_held_to_a_fast_line_s_bar(self):
|
|
# The whole reason the number is a fraction: 5 MB/s is a tenth of
|
|
# this laptop's line and more than a 10 Mbps line can ever carry, so
|
|
# a fixed rate would switch the split off entirely down there.
|
|
d = self.daemon(10.0)
|
|
floor = d.load_floor_bps(1000.0)
|
|
self.assertLess(floor, 10.0 * 1e6 / 8)
|
|
self.assertGreaterEqual(floor, LOAD_FLOOR_BPS)
|
|
|
|
def test_an_unmeasured_line_falls_back_to_the_floor(self):
|
|
self.assertEqual(self.daemon(None).load_floor_bps(1000.0), LOAD_FLOOR_BPS)
|
|
|
|
def test_background_chatter_never_reaches_the_floor(self):
|
|
# The stored median minute and p75 on this machine.
|
|
d = self.daemon(400.0)
|
|
floor = d.load_floor_bps(1000.0)
|
|
for chatter in (18_020, 42_290, 182_094):
|
|
self.assertLess(chatter, floor)
|
|
|
|
def test_it_is_read_once_a_minute_not_twice_a_second(self):
|
|
d = self.daemon(400.0)
|
|
for tick in range(0, 120, 1):
|
|
d.load_floor_bps(1000.0 + tick * 0.5)
|
|
self.assertLessEqual(d.store.calls, 2)
|
|
|
|
def test_a_store_that_throws_does_not_stop_the_probes(self):
|
|
d = self.daemon(400.0)
|
|
|
|
def boom(**kw):
|
|
raise RuntimeError("db is busy")
|
|
|
|
d.store.baseline_speed = boom
|
|
self.assertEqual(d.load_floor_bps(1000.0), LOAD_FLOOR_BPS)
|
|
|
|
|
|
class RoamDoesNotResetTheSeries(unittest.TestCase):
|
|
"""A roam is the same network, so the history must survive it.
|
|
|
|
Raised by the HopSense session 2026-09-12: their sparkline filters rows by
|
|
network fingerprint because their sink keeps every network a device has
|
|
been on. Ours resets upstream instead, on a route change — so the question
|
|
is whether a BSSID change without a gateway change counts as one. It must
|
|
not: this laptop's own network kicked every station once a minute for a
|
|
while, and a series that reset on each roam would be permanently empty.
|
|
"""
|
|
|
|
def daemon(self, route):
|
|
d = Daemon.__new__(Daemon)
|
|
d.route = route
|
|
d.config = {"internetAnchor": "1.1.1.1"}
|
|
d.rebuilt = []
|
|
d._rebuild_probes = lambda fresh: d.rebuilt.append(fresh)
|
|
return d
|
|
|
|
def run_with(self, current, fresh):
|
|
d = self.daemon(current)
|
|
real = daemon_mod.net.route_to
|
|
daemon_mod.net.route_to = lambda anchor: fresh
|
|
try:
|
|
Daemon.restart_probes_if_route_changed(d)
|
|
finally:
|
|
daemon_mod.net.route_to = real
|
|
return d.rebuilt
|
|
|
|
def test_a_roam_keeps_the_series(self):
|
|
# Same gateway, same interface: only the access point changed.
|
|
here = {"gateway": "192.168.10.1", "iface": "wlo1"}
|
|
self.assertEqual(self.run_with(here, dict(here)), [])
|
|
|
|
def test_a_different_gateway_resets(self):
|
|
rebuilt = self.run_with({"gateway": "192.168.10.1", "iface": "wlo1"},
|
|
{"gateway": "10.0.0.1", "iface": "wlo1"})
|
|
self.assertEqual(len(rebuilt), 1)
|
|
|
|
def test_a_different_interface_resets(self):
|
|
# Docking: the same address on a different link is a different path.
|
|
rebuilt = self.run_with({"gateway": "192.168.10.1", "iface": "wlo1"},
|
|
{"gateway": "192.168.10.1", "iface": "eth0"})
|
|
self.assertEqual(len(rebuilt), 1)
|
|
|
|
def test_losing_the_route_is_an_outage_not_a_new_network(self):
|
|
# Resetting here throws away the run-up to the drop, which is the one
|
|
# window a user wants afterwards.
|
|
self.assertEqual(
|
|
self.run_with({"gateway": "192.168.10.1", "iface": "wlo1"},
|
|
{"gateway": None, "iface": "wlo1"}), [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|