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
2026-09-23 15:19:12 +07:00
commit 1cdb82a76f
300 changed files with 78143 additions and 0 deletions
@@ -0,0 +1,8 @@
"""nexthopd — the measurement daemon behind the Nexthop Omarchy plugin.
Standard library only, on purpose: the plugin is installed by cloning a git
repo, and the marketplace installer never builds or runs anything. Anything
that needed pip or a compiler would make installation a second, manual step.
"""
__version__ = "0.2.42"
@@ -0,0 +1,5 @@
"""`python3 -m nexthopd` runs the daemon; the CLI lives at nexthopd.cli."""
import sys
from .daemon import main
sys.exit(main())
@@ -0,0 +1,403 @@
"""Per-application traffic, without root.
Linux only hands per-process byte counts to privileged tooling (pcap, eBPF)
— that is why nethogs needs root. The unprivileged truth available is
`ss -tinp`: every TCP socket's bytes_sent / bytes_received with the owning
process. Sampled on an interval, socket deltas aggregate into honest
per-app rates and running totals.
What this cannot see: UDP — and with it QUIC, which is how Chrome talks to
much of Google. That traffic surfaces as the gap between the interface
counters and the TCP sum, shown as its own "unattributed" bucket rather
than silently missing.
The same `ss` line also carries what the kernel already knows about each
connection's timing: `rtt:<srtt>/<rttvar>` (its smoothed round trip),
`minrtt` (the lowest it has ever seen on that path) and `bytes_retrans`.
That is a latency measurement of the user's own traffic, to the hosts they
actually talk to, costing no probe and no privilege — so we read it rather
than throw it away.
`minrtt` is the path's structural floor: distance, switching, serialisation.
`srtt - minrtt` is therefore queueing delay with distance divided out, and
every connection is its own control — which is what makes a 300 ms socket to
another continent comparable with a 5 ms socket next door. A raw `srtt` is
never a verdict on its own; only the difference travels.
"""
import os
import re
import select
import shutil
import statistics
import subprocess
import time
from collections import deque
from typing import NamedTuple
from .probes import nearest_rank
# users:(("chrome",pid=4958,fd=66)) — first process owning the socket.
RE_USER = re.compile(r'users:\(\("([^"]+)",pid=(\d+)')
RE_SENT = re.compile(r"bytes_sent:(\d+)")
RE_RECV = re.compile(r"bytes_received:(\d+)")
# rtt:<srtt>/<rttvar> — both milliseconds. minrtt is printed separately.
RE_RTT = re.compile(r"\brtt:([\d.]+)/([\d.]+)")
RE_MINRTT = re.compile(r"\bminrtt:([\d.]+)")
RE_RETRANS = re.compile(r"\bbytes_retrans:(\d+)")
class Sock(NamedTuple):
"""One socket's sample. Indexable, so older positional callers still work."""
app: str
pid: int
sent: int
recv: int
srtt: float = None # kernel smoothed RTT, ms
minrtt: float = None # lowest RTT seen on this path, ms
retrans: int = 0 # bytes retransmitted by the kernel
# A connection has to have carried something before its floor is worth
# trusting: `minrtt` is a minimum over the connection's life, so a socket
# that has only ever been busy may never have seen a quiet moment. Note the
# error direction — a floor biased high UNDER-states queueing, so this
# threshold is about honesty, not safety. Argue with the number, not the rule.
MIN_LATENCY_BYTES = 4096
# A plausibility ceiling, distinct from a sample-count floor: past this, the
# number describes a broken measurement rather than a slow link, and a
# measurement we cannot believe is worth less than no measurement.
MAX_PLAUSIBLE_RTT_MS = 10_000.0
# `ss` prints three decimals, so allow rounding before calling an inverted
# pair (floor above the average, which cannot happen) a stale field.
RTT_INVERSION_TOLERANCE_MS = 0.05
# Below this many qualifying sockets we publish nothing rather than a
# distribution drawn from a handful of connections.
MIN_LATENCY_SOCKETS = 3
def socket_timing(s) -> tuple:
"""(srtt, floor, queue) for one socket, or None if it does not qualify.
One guard, used by both the aggregate and the per-app medians, so a
socket rejected in one place cannot be silently counted in the other.
"""
if s.srtt is None or s.minrtt is None:
return None # kernel has no timing for it yet
if s.sent + s.recv < MIN_LATENCY_BYTES:
return None # too little traffic to trust the floor
if s.srtt <= 0 or s.minrtt <= 0:
return None
if s.srtt > MAX_PLAUSIBLE_RTT_MS or s.minrtt > MAX_PLAUSIBLE_RTT_MS:
return None # implausible: a broken measurement
if s.minrtt > s.srtt + RTT_INVERSION_TOLERANCE_MS:
return None # floor above the average: stale field
return (s.srtt, s.minrtt, max(0.0, s.srtt - s.minrtt))
def latency_stats(socks: dict) -> dict:
"""What the user's own TCP connections are experiencing, or None.
Pure and injected so it can be tested without a live socket table.
Returns None when too few connections qualify — an honest blank beats a
distribution invented from three sockets.
"""
srtts, floors, queues, retrans_socks, rejected = [], [], [], 0, 0
for s in socks.values():
if s.srtt is None or s.minrtt is None:
continue # not a rejection: the kernel simply has no timing
t = socket_timing(s)
if t is None:
rejected += 1
continue
srtts.append(t[0])
floors.append(t[1])
queues.append(t[2])
if s.retrans:
retrans_socks += 1
if len(srtts) < MIN_LATENCY_SOCKETS:
return None
srtts.sort(); floors.sort(); queues.sort()
return {
"sockets": len(srtts),
"rejected": rejected,
# What the applications see, end to end, including distance.
"rtt_p50": round(statistics.median(srtts), 2),
"rtt_p95": round(nearest_rank(srtts, 0.95), 2),
# The structural floor of the paths in use.
"floor_p50": round(statistics.median(floors), 2),
# Queueing, with distance divided out. This is the number that
# compares across connections.
"queue_p50": round(statistics.median(queues), 2),
"queue_p95": round(nearest_rank(queues, 0.95), 2),
"retrans_sockets": retrans_socks,
}
def parse_ss(raw: str, max_sockets: int = 10_000) -> dict:
"""{socket_key: (app, pid, sent, received)} from `ss -tinpH` output.
Sockets are keyed by local/peer address pair plus pid, which survives
across samples for the life of the connection. Sockets without process
attribution (other users' processes) are skipped, and parsing stops at
max_sockets so a pathological table cannot expand retained state.
"""
out = {}
addr = None
app = None
pid = None
for line in raw.splitlines():
if not line.startswith(("\t", " ")):
# Header line: state, queues, local, peer, users.
parts = line.split()
addr = None
app = None
m = RE_USER.search(line)
if m and len(parts) >= 5:
addr = parts[3] + ">" + parts[4]
app = m.group(1)
pid = int(m.group(2))
continue
if addr is None:
continue
sent = RE_SENT.search(line)
recv = RE_RECV.search(line)
if sent or recv:
if len(out) >= max_sockets:
break
# The kernel's own timing, off the same line. Absent on a socket
# it has not measured yet, which is why these stay None rather
# than defaulting to zero.
rtt = RE_RTT.search(line)
minrtt = RE_MINRTT.search(line)
retrans = RE_RETRANS.search(line)
out[addr + "#" + str(pid)] = Sock(
app, pid,
int(sent.group(1)) if sent else 0,
int(recv.group(1)) if recv else 0,
float(rtt.group(1)) if rtt else None,
float(minrtt.group(1)) if minrtt else None,
int(retrans.group(1)) if retrans else 0,
)
addr = None
return out
# Reaping is inside the budget, not after it. This whole call runs on the
# daemon's loop, so what the caller needs bounded is the block the loop
# suffers — the read plus getting rid of the child. Spending the deadline
# reading and then seconds terminating meets the letter and misses the
# point; that gap is what let a slow `ss` push live.json past the age at
# which the bar calls the daemon dead.
REAP_RESERVE_S = 0.25
def read_bounded(proc, max_bytes: int, deadline_s: float):
"""The child's stdout, capped in size and in time, then the child reaped.
Returns the text, or None if the process did not finish within the
deadline — it is killed and waited for either way, so nothing lingers.
Output past the cap is discarded and the process stopped: the sample
stays bounded and simply under-counts, which is the existing contract.
`deadline_s` bounds the CALL, reaping included, because that is the
figure the caller budgets against.
"""
fd = proc.stdout.fileno()
os.set_blocking(fd, False)
reserve = max(0.05, min(REAP_RESERVE_S, deadline_s * 0.5))
deadline = time.monotonic() + max(0.0, deadline_s - reserve)
chunks, total, timed_out = [], 0, False
try:
while total <= max_bytes:
remaining = deadline - time.monotonic()
if remaining <= 0:
timed_out = True
break
ready, _, _ = select.select([fd], [], [], remaining)
if not ready:
continue
try:
chunk = os.read(fd, min(65536, max_bytes + 1 - total))
except BlockingIOError:
continue
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
except OSError:
timed_out = True
finally:
proc.stdout.close()
_reap(proc, reserve)
if timed_out:
return None
return b"".join(chunks)[:max_bytes].decode("utf-8", "replace")
def _reap(proc, budget_s: float):
"""Terminate if still running, escalate to kill, and wait — within
`budget_s`, because this runs on the daemon's loop.
A signalled child that is never waited for is a zombie until the next
Popen happens to collect it, so waiting is right; waiting without a
bound is not. A child that survives SIGKILL for longer than this is in
uninterruptible sleep, and no amount of further waiting is going to
help — leaving it for the next poll to collect costs a transient
zombie, while blocking here costs the snapshot the whole bar reads.
"""
half = max(0.05, budget_s * 0.5)
try:
if proc.poll() is None:
proc.terminate()
proc.wait(timeout=half)
except subprocess.TimeoutExpired:
try:
proc.kill()
proc.wait(timeout=half)
except (OSError, subprocess.TimeoutExpired):
pass
except OSError:
pass
class AppTraffic:
"""Aggregates socket samples into per-app rates and session totals."""
# How many poll intervals of history each app keeps. At the 3-second
# poll that is one minute — enough to show the shape of usage, small
# enough to ride along in apps.json.
HISTORY = 20
def __init__(self):
self.prev = {}
self.prev_t = None
self.totals = {} # app -> [rx_bytes, tx_bytes]
self.rates = [] # last interval's list, ready for apps.json
self.history = {} # app -> deque of [rx_bps, tx_bps]
self.latency = None # last latency_stats(), or None while under-sampled
# Enumeration bounds: a machine with an enormous socket table must not
# make the daemon allocate without limit every three seconds. 4 MB of
# `ss` output is roughly 8000 sockets — far past any laptop, and the
# cap degrades to "top apps among the first N sockets", not a crash.
MAX_SS_BYTES = 4 * 1024 * 1024
MAX_SOCKETS = 10_000
# `ss -p` walks every process's descriptors to name the owners, and
# on a busy machine that can stall. The read is bounded in time as
# well as size, because a read with no deadline holds the daemon's
# loop — and its outage watch — for as long as `ss` does.
#
# The size of the bound is not free either. This call is the only
# blocking one left on the loop, and the loop writes live.json just
# before it, so whatever this is budgeted for is how stale that
# snapshot can get. The bar calls the daemon dead at
# `BarWidget.staleAfterS` (5 s) — so a 5 s budget here, which is what
# this was, could blank the index, the headline and the path
# sparklines' liveness ring on a daemon that was measuring perfectly.
# A third of the contract leaves room for the rest of the iteration.
# Overrunning it costs one interval of app counters, which the Apps
# tab already degrades honestly; the alternative cost the whole bar.
# A test pins this against the QML number rather than either alone.
POLL_DEADLINE_S = 1.5
def poll(self) -> bool:
if not shutil.which("ss"):
return False
try:
proc = subprocess.Popen(["ss", "-tinpH"], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
except OSError:
return False
raw = read_bounded(proc, self.MAX_SS_BYTES, self.POLL_DEADLINE_S)
if raw is None:
return False
now = time.time()
cur = parse_ss(raw, max_sockets=self.MAX_SOCKETS)
self._fold(cur, now)
return True
def _fold(self, cur: dict, now: float):
if self.prev_t is None:
# First sample is the baseline: the counters carry each
# connection's whole history, which is not this interval's
# traffic.
self.prev, self.prev_t = cur, now
return
dt = max(0.5, now - self.prev_t)
interval = {} # app -> [rx, tx, conns]
per_app_rtt = {} # app -> list of srtt
per_app_queue = {} # app -> list of srtt - minrtt
for key, s in cur.items():
app, sent, recv = s.app, s.sent, s.recv
prev = self.prev.get(key)
if prev is not None:
d_tx = max(0, sent - prev[2])
d_rx = max(0, recv - prev[3])
else:
# Born since the last sample: its whole life is this
# interval.
d_tx, d_rx = sent, recv
slot = interval.setdefault(app, [0, 0, 0])
slot[0] += d_rx
slot[1] += d_tx
slot[2] += 1
# Per-app timing shares the aggregate's guard, so a socket
# rejected there cannot be silently counted here.
t = socket_timing(s)
if t is not None:
per_app_rtt.setdefault(app, []).append(t[0])
per_app_queue.setdefault(app, []).append(t[2])
# Sockets that closed between samples take their final delta with
# them — the tail of a closed connection is the one thing this
# method genuinely cannot count.
for app, (rx, tx, _conns) in interval.items():
tot = self.totals.setdefault(app, [0, 0])
tot[0] += rx
tot[1] += tx
# Every known app gets a history sample each interval — an app that
# went quiet records zeros, so its strip shows the quiet.
for app in self.totals:
v = interval.get(app)
h = self.history.setdefault(app, deque(maxlen=self.HISTORY))
h.append([round(v[0] / dt, 1), round(v[1] / dt, 1)] if v else [0.0, 0.0])
self.rates = [
{
"name": app,
"rx_bps": round(v[0] / dt, 1),
"tx_bps": round(v[1] / dt, 1),
"conns": v[2],
"rx_total": self.totals.get(app, [0, 0])[0],
"tx_total": self.totals.get(app, [0, 0])[1],
"hist": list(self.history.get(app, [])),
# Median across this app's qualifying sockets. None when it
# has none — an app talking only QUIC shows no latency here,
# which is honest rather than zero.
"rtt_ms": (round(statistics.median(per_app_rtt[app]), 2)
if per_app_rtt.get(app) else None),
"queue_ms": (round(statistics.median(per_app_queue[app]), 2)
if per_app_queue.get(app) else None),
}
for app, v in interval.items()
]
self.latency = latency_stats(cur)
self.prev, self.prev_t = cur, now
def top(self, n: int = 8) -> list:
"""Busiest apps first; idle-but-heavy session users still listed."""
ranked = sorted(self.rates,
key=lambda a: (a["rx_bps"] + a["tx_bps"],
a["rx_total"] + a["tx_total"]),
reverse=True)
seen = {a["name"] for a in ranked}
# Apps with session history but no sockets this interval.
idle = [
{"name": app, "rx_bps": 0.0, "tx_bps": 0.0, "conns": 0,
"rx_total": t[0], "tx_total": t[1],
"hist": list(self.history.get(app, [])),
"rtt_ms": None, "queue_ms": None}
for app, t in self.totals.items() if app not in seen
]
idle.sort(key=lambda a: a["rx_total"] + a["tx_total"], reverse=True)
return (ranked + idle)[:n]
@@ -0,0 +1,365 @@
"""`nexthop` — the query CLI the panel (and you) use for history.
Everything answers in JSON on stdout, because the consumer is a QML
Process { } as often as it is a person. The daemon is not involved: reads go
straight to the files — sqlite for history (WAL mode makes that safe), the
JSON state files for the rest.
nexthop query --window 24h history series at the right resolution
nexthop live the current live.json
nexthop events --window 7d outages, disruptions, changes
nexthop tests [--kind peak] speed test results
nexthop report --window 24h plain-text summary for an ISP ticket
nexthop peak ask the running daemon for a peak test
nexthop stream <keys> the shell's bounded reader: one JSON line
per named state file, re-emitted on change
nexthop retire --pid --start SIGTERM a stale daemon, identity-checked
"""
import argparse
import fcntl
import json
import os
import signal
import stat
import sys
import time
from .paths import (apps_path, db_path, live_path, lock_path, manifest_path,
recent_path)
from .state import read_json, read_text_bounded
WINDOWS = {"m": 60, "h": 3600, "d": 86400}
def parse_window(text: str) -> float:
text = (text or "30m").strip().lower()
unit = text[-1]
if unit in WINDOWS:
try:
return float(text[:-1]) * WINDOWS[unit]
except ValueError:
pass
try:
return float(text)
except ValueError:
return 1800.0
def emit(obj):
json.dump(obj, sys.stdout, separators=(",", ":"))
print()
def cmd_live(_args):
emit(read_json(live_path(), {"state": "no-daemon"}))
return 0
# The QML side never opens a state file itself. It runs `nexthop stream`
# and reads whole lines, so the only code that touches these paths is the
# bounded no-follow non-blocking read in state.py — an oversized file, a
# FIFO or a symlink swap is refused here, in a small short-lived process,
# instead of allocating or stalling inside the long-lived shell.
#
# Keys, never paths: the caller picks from this table, so no argument it
# passes can widen what gets opened. Caps match each file's real size
# (live ~3 KB, apps ~8 KB, recent ~30 KB) with generous headroom.
STREAMABLE = {
"live": (live_path, 256 * 1024),
"apps": (apps_path, 1024 * 1024),
"recent": (recent_path, 1024 * 1024),
"manifest": (manifest_path, 256 * 1024),
}
def cmd_stream(args):
"""Emit `<key> <json>` lines whenever a watched file's contents change.
The payload is re-serialised here rather than forwarded verbatim: it
guarantees one line per record whatever the file's own formatting
(manifest.json is indented, the state files are not), and it means
only JSON this process already parsed successfully is ever handed to
the shell.
"""
keys = [k for k in dict.fromkeys(args.keys) if k in STREAMABLE]
if not keys:
print("stream: nothing to watch", file=sys.stderr)
return 2
interval = min(max(args.interval, 0.1), 60.0)
last = {}
while True:
for key in keys:
resolve, cap = STREAMABLE[key]
got = read_text_bounded(resolve(), cap)
if got is None:
continue
text, stamp = got
if last.get(key) == stamp:
continue
last[key] = stamp
try:
payload = json.loads(text)
except ValueError:
continue # a half-written or foreign file; skip it
# ensure_ascii escapes any newline inside a string, so the
# record cannot break the line framing.
line = json.dumps(payload, separators=(",", ":"))
try:
sys.stdout.write(f"{key} {line}\n")
sys.stdout.flush()
except (BrokenPipeError, ValueError):
# The shell went away; so do we. _exit skips the
# interpreter's final flush, which would only raise the
# same broken pipe again and print it to stderr.
os._exit(0)
time.sleep(interval)
def authorized_to_retire(pid: int, want_start: int) -> bool:
"""Is this pid really our daemon, and the same one live.json named?
Three independent facts, all read from /proc and none of them a name
match: the process must belong to this user, its argv must be exactly
a python interpreter running `-m nexthopd`, and its start time must
equal the one the daemon published. A recycled pid can reproduce the
number but never the start time.
This lives here rather than in a shell one-liner because the one-liner
could not be tested and, as it turned out, did not run at all: the
NUL it passed to `tr` truncated the script at execve.
"""
try:
if os.stat(f"/proc/{pid}").st_uid != os.getuid():
return False
with open(f"/proc/{pid}/cmdline", "rb") as f:
argv = [a.decode("utf-8", "replace")
for a in f.read(4096).split(b"\0") if a]
with open(f"/proc/{pid}/stat", "rb") as f:
data = f.read(4096)
except (OSError, ValueError):
return False
if len(argv) < 3 or "python" not in os.path.basename(argv[0]):
return False
if argv[1] != "-m" or argv[2] != "nexthopd" or len(argv) > 3:
return False
if want_start:
try:
start = int(data[data.rindex(b")") + 2:].split()[19])
except (ValueError, IndexError):
return False
if start != want_start:
return False
return True
def cmd_retire(args):
"""SIGTERM a stale daemon, but only once its identity checks out."""
if args.pid <= 0 or args.pid == os.getpid():
return 1
if not authorized_to_retire(args.pid, args.start):
return 1
try:
os.kill(args.pid, signal.SIGTERM)
except OSError:
return 1
return 0
def open_store():
# Imported here, not at module scope: `stream` runs for the life of the
# shell and has no use for sqlite3, so it should not pay to load it.
from .store import Store
path = db_path()
if not path.exists():
return None
try:
return Store(path, read_only=True)
except Exception:
return None
def cmd_query(args):
store = open_store()
if not store:
emit({"error": "no history yet"})
return 1
seconds = parse_window(args.window)
rows, table = store.series(seconds, resolution=args.resolution)
emit({"window_s": seconds, "resolution": table, "rows": rows})
return 0
def cmd_events(args):
store = open_store()
if not store:
emit({"events": []})
return 0
emit({"events": store.events(parse_window(args.window))})
return 0
def cmd_tests(args):
store = open_store()
if not store:
emit({"tests": []})
return 0
emit({"tests": store.tests(limit=args.limit, kind=args.kind)})
return 0
def _lock_holder_pid() -> int:
"""The pid written by whoever holds the daemon lock, or 0.
The lock file outlives a daemon that died hard and pids are recycled,
so the number in the file is not evidence on its own. The flock is:
if this process can take it, nobody holds it and nobody is listening,
whatever the file says. Opened the way the daemon opens it — no
symlink following, a regular file or nothing — and never truncated.
"""
try:
fd = os.open(lock_path(), os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError:
return 0
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
return 0
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
# Held: that is the daemon, and the pid it wrote after taking
# the lock is the one to ring.
try:
return int(os.read(fd, 32).strip())
except ValueError:
return 0
# We got it, so nobody was holding it. Give it straight back.
fcntl.flock(fd, fcntl.LOCK_UN)
return 0
except OSError:
return 0
finally:
os.close(fd)
def cmd_peak(_args):
"""Ring the daemon's doorbell. SIGUSR1 is the whole protocol.
SIGUSR1's default disposition is *terminate*, so signalling the wrong
pid is not a harmless no-op — and this is what a middle-click on the
bar runs. Two checks stand before the signal: the lock must actually
be held (see `_lock_holder_pid`), and the holder must pass the same
owner-and-argv test the retire path applies, with the start time
live.json published when it names the same pid. The same rule as
`nexthop retire`: a process is authorised by identity, never by the
number a file happens to contain.
"""
pid = _lock_holder_pid()
if pid <= 0 or pid == os.getpid():
emit({"ok": False, "error": "daemon not running"})
return 1
live = read_json(live_path(), {}) or {}
want_start = 0
if live.get("pid") == pid:
try:
want_start = int(live.get("pid_start") or 0)
except (TypeError, ValueError):
want_start = 0
if not authorized_to_retire(pid, want_start):
emit({"ok": False, "error": "lock holder is not nexthopd"})
return 1
try:
os.kill(pid, signal.SIGUSR1)
except OSError:
emit({"ok": False, "error": "daemon not running"})
return 1
emit({"ok": True})
return 0
def cmd_report(args):
"""The paste-into-a-ticket summary. Plain text by design."""
store = open_store()
live = read_json(live_path(), {})
seconds = parse_window(args.window)
lines = []
link = live.get("link", {})
lines.append(f"Nexthop report — last {args.window}")
lines.append(f"generated {time.strftime('%Y-%m-%d %H:%M %Z')}")
if link:
what = link.get("ssid") or link.get("name") or link.get("iface", "?")
lines.append(f"connection: {what} ({link.get('kind', '?')}), "
f"gateway {link.get('gateway', '?')}")
lines.append("")
if store:
rows, table = store.series(seconds)
vals = lambda k: [r[k] for r in rows if r.get(k) is not None]
def block(name, p50key, p95key, losskey):
p50, p95, loss = vals(p50key), vals(p95key), vals(losskey)
if not p50:
lines.append(f"{name}: no data")
return
lines.append(
f"{name}: median {sum(p50)/len(p50):.1f} ms, "
f"p95 {max(p95) if p95 else 0:.1f} ms (worst {table} bucket), "
f"loss {sum(loss)/len(loss)*100 if loss else 0:.2f}%")
block("local leg (to router)", "local_p50", "local_p95", "local_loss")
block("wan leg (past router)", "wan_p50", "wan_p95", "wan_loss")
lines.append("")
events = store.events(seconds)
if events:
lines.append("events:")
for e in events:
start = time.strftime("%a %H:%M", time.localtime(e["ts"]))
dur = (f"{e['ended_ts'] - e['ts']}s" if e["ended_ts"]
else "ongoing")
lines.append(f" {start} {e['kind']} on {e['leg']} leg, {dur}"
f" — {e['detail']}")
else:
lines.append("events: none")
tests = store.tests(limit=5)
if tests:
lines.append("")
lines.append("speed tests:")
for t in tests:
when = time.strftime("%a %H:%M", time.localtime(t["ts"]))
down = f"{t['down_mbps']:.0f}" if t["down_mbps"] else "--"
up = f"{t['up_mbps']:.0f}" if t["up_mbps"] else "--"
lines.append(f" {when} {t['kind']:<8} {down}/{up} Mbps"
f" ({t['engine']})")
print("\n".join(lines))
return 0
def main(argv=None):
ap = argparse.ArgumentParser(prog="nexthop")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("live")
q = sub.add_parser("query")
q.add_argument("--window", default="30m")
q.add_argument("--resolution", default="auto",
choices=["auto", "minute", "hour"])
e = sub.add_parser("events")
e.add_argument("--window", default="7d")
t = sub.add_parser("tests")
t.add_argument("--kind", default=None)
t.add_argument("--limit", type=int, default=20)
sub.add_parser("peak")
r = sub.add_parser("report")
r.add_argument("--window", default="24h")
s = sub.add_parser("stream")
s.add_argument("keys", nargs="+", choices=sorted(STREAMABLE))
s.add_argument("--interval", type=float, default=0.5)
rt = sub.add_parser("retire")
rt.add_argument("--pid", type=int, required=True)
rt.add_argument("--start", type=int, default=0)
args = ap.parse_args(argv)
return {"live": cmd_live, "query": cmd_query, "events": cmd_events,
"tests": cmd_tests, "peak": cmd_peak, "report": cmd_report,
"stream": cmd_stream, "retire": cmd_retire}[args.cmd](args)
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,353 @@
"""Two best instruments for the internet leg.
One anchor is one opinion. A single probe target that rate-limits, gets
DDoSed, or sits behind a bad peering path poisons the score for everyone
behind it — and there is no way to tell "my internet is slow" from "the
anchor is having a day" with one instrument. Orb's answer, adopted here:
keep a small pool of instruments, score from the best two, re-rank them
continuously, and never let one flapping target churn the pair.
An instrument is a (protocol, target) pair. The pool mixes protocols on
purpose: TCP handshakes travel the application path, ICMP is the cheapest
edge detector — each can fail alone, and the pair means neither failing
alone moves the score. Ranking is arguable-by-design, one number at a
time, like the score anchors:
penalty = 2000·loss + (p95 − p50) + 0.1·p50
Loss dominates (5 % of packets lost costs as much as 100 ms of tail spread), the
tail spread comes second because Lag leans on p75, and the median is a
tiebreak — an instrument must not win its seat merely by being close.
Damping, because re-selection is where naive versions of this oscillate:
a challenger must beat the worst active instrument by 20 % on two
consecutive evaluations; an instrument whose seat changes three or more
times in an hour is quarantined for thirty minutes; and a dead active
instrument is replaced immediately, hysteresis notwithstanding — waiting
two rounds to bench a corpse helps nobody.
Everything here is pure bookkeeping over injected stats. No probe, no
subprocess, no clock of its own — which is what makes it testable, and
tested.
"""
import statistics
from collections import deque
from .probes import RECENT_MIN_SAMPLES, RECENT_WINDOW_S, Series
DEAD_PENALTY = 2000.0 # loss = 1.0 and nothing else to say
def penalty(stats):
"""Rank one instrument's recent window; None = not enough to judge."""
if not stats:
return None
count = stats.get("count") or 0
if count < Bench.MIN_SAMPLES:
return None
loss = stats.get("loss") or 0.0
p50, p95 = stats.get("p50"), stats.get("p95")
if p50 is None:
return DEAD_PENALTY * loss if loss > 0 else None
spread = (p95 - p50) if p95 is not None else 0.0
return DEAD_PENALTY * loss + spread + 0.1 * p50
def host_of(target: str) -> str:
"""The address part of an instrument's target.
So that two instruments pointed at the same machine are recognisable as
such: `1.1.1.1` and `1.1.1.1:443` are one host wearing two protocols. A
trailing `:port` is stripped only when what remains holds no colon of its
own, which leaves an IPv6 literal intact rather than truncating it.
"""
head, sep, tail = target.rpartition(":")
if sep and tail.isdigit() and ":" not in head:
return head
return target
class Instrument:
def __init__(self, key: str, kind: str, target: str = ""):
self.key = key
self.kind = kind # "icmp" | "tcp"
self.target = target
self.active = False
self.pending_wins = 0 # consecutive evaluations won as challenger
self.seat_changes = deque(maxlen=32) # timestamps, for flap tracking
self.quarantined_until = 0.0
def flapping(self, now, window_s, limit) -> bool:
return sum(1 for t in self.seat_changes if now - t <= window_s) >= limit
class Bench:
"""Holds the pool, decides who sits in the two scored seats."""
ACTIVE_N = 2
WINDOW_S = RECENT_WINDOW_S # ranking window, shared with TcpProbe
RESELECT_EVERY_S = 300.0 # ordinary re-ranking cadence
MIN_SAMPLES = RECENT_MIN_SAMPLES # below this a window judges nothing
MARGIN = 0.8 # challenger must be 20% better than the seat
CONSECUTIVE_WINS = 2
FLAP_WINDOW_S = 3600.0
FLAP_LIMIT = 3
QUARANTINE_S = 1800.0
# An active instrument at or past this penalty is not "worse", it is
# gone — full loss, or no samples arriving at all.
DEAD_AT = DEAD_PENALTY * 0.95
def __init__(self, pool):
"""pool: ordered [(key, kind, target)].
The opening seats span two DISTINCT hosts rather than being the first
two in the pool. They used to be `pool[:2]` — ICMP and TCP to the
anchor, the pre-0.2.0 pair, kept for continuity until the first
ranking. On any network that blocks the anchor outright that put both
scored seats on a dead host at every start: the outage watch opens at
4 s, the notification fires at 5 s, and the bench cannot reseat until
its next pass a minute later. A false outage and a desktop alert on
every daemon start, and the daemon restarts on a shell restart, a
version handover and a probe-settings change (#5).
One working seat is enough to prevent it — the leg answers if either
instrument does — so the rule is simply that the pair must not be one
host twice. The bench re-ranks from there as it always did; this only
decides what is seated before there is anything to rank.
Not fixed by changing the default anchor: every candidate address is
blocked on somebody's network, so that moves the report rather than
closing it.
"""
self.instruments = {}
for key, kind, target in pool:
self.instruments[key] = Instrument(key, kind, target)
for inst in self._opening_seats():
inst.active = True
self._last_reselect = 0.0
def _opening_seats(self):
"""The first instruments of the pool that do not share a host."""
seats, hosts = [], set()
for inst in self.instruments.values():
if len(seats) >= self.ACTIVE_N:
break
host = host_of(inst.target)
if host in hosts:
continue
seats.append(inst)
hosts.add(host)
# A pool offering fewer distinct hosts than there are seats fills the
# rest in order: fewer scored instruments than the bench expects is a
# worse failure than two of them sharing a host.
if len(seats) < self.ACTIVE_N:
for inst in self.instruments.values():
if len(seats) >= self.ACTIVE_N:
break
if inst not in seats:
seats.append(inst)
return seats
def actives(self):
return [i for i in self.instruments.values() if i.active]
def _healthy(self, pens, inst, now):
p = pens.get(inst.key)
return (p is not None and p < self.DEAD_AT
and now >= inst.quarantined_until)
def _seat(self, inst, now, active: bool):
if inst.active == active:
return None
inst.active = active
inst.pending_wins = 0
inst.seat_changes.append(now)
if inst.flapping(now, self.FLAP_WINDOW_S, self.FLAP_LIMIT):
inst.quarantined_until = now + self.QUARANTINE_S
return (inst.key, active)
def evaluate(self, now, stats_by_key):
"""One pass; returns [(key, now_active)] seat changes.
Call every minute or so: emergency replacement of a dead seat acts
on any pass, ordinary re-ranking only every RESELECT_EVERY_S.
"""
pens = {k: penalty(stats_by_key.get(k)) for k in self.instruments}
changes = []
# A dead seat is replaced now. No hysteresis for corpses — but no
# churn during a full outage either: promotion needs a healthy
# standby, and when everything is dead the pair stands still.
for inst in self.actives():
p = pens.get(inst.key)
if p is not None and p < self.DEAD_AT:
continue
standbys = [i for i in self.instruments.values()
if not i.active and self._healthy(pens, i, now)]
if not standbys:
continue
best = min(standbys, key=lambda i: pens[i.key])
changes += filter(None, [self._seat(inst, now, False),
self._seat(best, now, True)])
if now - self._last_reselect < self.RESELECT_EVERY_S:
return changes
self._last_reselect = now
# Ordinary re-ranking, damped. The worst seat defends against the
# best healthy challenger; a challenger that stops winning starts
# over from zero.
actives = [i for i in self.actives() if pens.get(i.key) is not None]
challengers = [i for i in self.instruments.values()
if not i.active and self._healthy(pens, i, now)]
for inst in self.instruments.values():
if not inst.active and inst not in challengers:
inst.pending_wins = 0
if not actives or not challengers:
return changes
worst = max(actives, key=lambda i: pens[i.key])
best = min(challengers, key=lambda i: pens[i.key])
for c in challengers:
if c is not best:
c.pending_wins = 0
if pens[best.key] < pens[worst.key] * self.MARGIN:
best.pending_wins += 1
if best.pending_wins >= self.CONSECUTIVE_WINS:
changes += filter(None, [self._seat(worst, now, False),
self._seat(best, now, True)])
else:
best.pending_wins = 0
return changes
def snapshot(self, now, stats_by_key):
"""For live.json: who is in the pool, who holds a seat, and how
each has been measuring — so the shell can show the bench."""
out = []
for inst in self.instruments.values():
st = stats_by_key.get(inst.key) or {}
out.append({
"key": inst.key, "kind": inst.kind, "target": inst.target,
"active": inst.active,
"quarantined": now < inst.quarantined_until,
"p50": st.get("p50"), "p95": st.get("p95"),
# Per instrument, so the field can show what the merged
# figure used to hide: jitter measured within one stream.
"jitter": st.get("jitter"),
"loss": st.get("loss"), "count": st.get("count") or 0,
})
return out
def merged_stats(sample_lists) -> dict:
"""Series.stats over several instruments, each counting once.
Pooling the seated instruments' raw samples and calling Series.stats on
the pile — what this replaces — got two things wrong, and both moved
the score:
* Jitter is RFC 3550 IPDV, the difference between consecutive replies,
and consecutive replies in a pooled stream come from different
instruments. Two perfectly stable instruments with different base
round trips read as jittery: replayed at this line's own figures
(ICMP 3.41 ms at 500 ms, TCP 4.82 ms at 1 s) the pool reported
0.93 ms of jitter from two streams with none, and on a router that
fast-paths ICMP (5 vs 15 ms) it reported 6.6 ms and took ten points
off Responsiveness for nothing.
* Every instrument was weighted by how often it happened to probe.
ICMP follows the probeIntervalMs setting and TCP is fixed at one a
second, so changing a setting changed p75, loss and the index while
the network stayed the same: the 5/15 case scored 100, 92.7 or 90.3
depending only on that number.
Here each instrument's replies carry weight 1/n, percentiles are the
weighted nearest rank over the pool, jitter is the mean of the
instruments' own IPDVs, and loss is the mean of their loss rates.
One instrument reduces to Series.stats exactly, so `lag_icmp` and the
local leg are untouched.
"""
lists = [lst for lst in sample_lists if lst]
if not lists:
return Series.stats([])
if len(lists) == 1:
return Series.stats(lists[0])
per = [Series.stats(lst) for lst in lists]
count = sum(p["count"] for p in per)
loss = statistics.fmean(p["loss"] for p in per)
replies = [[smp[1] for smp in lst if smp[1] is not None] for lst in lists]
voiced = [r for r in replies if r]
if not voiced:
return {"count": count, "loss": loss, "p50": None, "p75": None,
"p95": None, "jitter": None, "last": None, "max": None}
pooled = []
for r in voiced:
w = 1.0 / len(r)
pooled.extend((v, w) for v in r)
pooled.sort(key=lambda x: x[0])
total_w = float(len(voiced))
def pct(p):
target = p * total_w - 1e-9
cum = 0.0
for v, w in pooled:
cum += w
if cum >= target:
return v
return pooled[-1][0]
jitters = [p["jitter"] for p in per if p["jitter"] is not None]
newest = None
for lst in lists:
for smp in reversed(lst):
if smp[1] is not None:
if newest is None or smp[0] > newest[0]:
newest = smp
break
return {
"count": count,
"loss": loss,
"p50": round(pct(0.5), 2),
"p75": round(pct(0.75), 2),
"p95": round(pct(0.95), 2),
"max": round(pooled[-1][0], 2),
"jitter": round(statistics.fmean(jitters), 2) if jitters else 0.0,
"last": round(newest[1], 2) if newest else None,
}
class MergedSeries:
"""A read-only view over whichever instruments hold the seats.
`.since()` and `.all()` return the pooled stream in time order — right
for anything that asks "did anyone reply between these two moments",
which is what outage detection does. Anything that turns the leg into
statistics goes through `.stats()` / `.each()` and `merged_stats`,
where the instruments count equally; see that function for why the
pooled stream must not be fed to Series.stats.
"""
def __init__(self, series_fn):
self._series_fn = series_fn # -> [Series] of the active seats
def each(self, seconds: float = None):
"""One sample list per seated instrument, the shape merged_stats wants."""
if seconds is None:
return [s.all() for s in self._series_fn()]
return [s.since(seconds) for s in self._series_fn()]
def stats(self, seconds: float) -> dict:
return merged_stats(self.each(seconds))
def since(self, seconds: float):
out = []
for s in self._series_fn():
out.extend(s.since(seconds))
out.sort(key=lambda smp: smp[0])
return out
def all(self):
out = []
for s in self._series_fn():
out.extend(s.all())
out.sort(key=lambda smp: smp[0])
return out
@@ -0,0 +1,194 @@
"""Who ended the last Wi-Fi association: the access point, this machine,
or nobody (the link fell over).
A BSSID change seen through `iw dev link` is only a fact: we were on one
radio and now we are on another. Whether that was the client's decision,
the access point's, or a link failure is the difference between a laptop
that roams, a router that kicks, and a driver that drops — three problems
with three owners, and the log used to call all of them "Roamed to".
`iw event` is the unprivileged view of nl80211's mlme multicast group.
Every deauthentication and disassociation frame the kernel sends or
receives is reported with its sender, receiver and 802.11 reason code, and
the authentication that follows is reported too. That is enough:
- the frame's sender says who ended it — the AP's address means it kicked
us, our own means this machine did;
- the reason code says why, in the AP's or the driver's own words;
- the delay before the next authentication says whether the client already
knew where it was going. mac80211 emits the local deauth from inside the
call that starts the new authentication when it roams, so a roam's gap is
milliseconds; a lost link is followed by a scan first, and its gap is
seconds.
Only the kernel talks to this module, through `iw`. Lines are read with a
length cap and matched against fixed patterns; two MACs and a reason code
are the only fields kept, and only our own reason texts reach the shell.
"""
import re
import shutil
import subprocess
import threading
import time
from collections import deque
# `iw event -t` line: "<secs>.<usecs>: <ifname> (phy #N): <event...>". The
# timestamp is CLOCK_REALTIME — the clock the daemon stamps with — and it
# is optional so recorded fixtures read the same with or without it.
_PREFIX = r"^(?:(\d+\.\d+): )?\S+(?: \(phy #\d+\))?: "
_MAC = r"([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})"
_ANY_MAC = r"[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}"
# A deauth/disassoc frame: "<sender> -> <receiver> reason N: <text>".
# "unprotected deauth" (a forged frame the kernel refused) does not match:
# the frame word must follow the prefix directly.
RE_FRAME = re.compile(_PREFIX + r"(deauth|disassoc) " + _MAC + " -> " + _MAC
+ r" reason (\d{1,5})\b")
# The first sign of the next association landing.
RE_NEXT = re.compile(_PREFIX + r"(?:(?:auth|assoc) " + _ANY_MAC + " -> "
+ _ANY_MAC + r" status: 0\b|(?:connected|roamed) to "
+ _ANY_MAC + r"\b)")
# 802.11 reason codes, in the words a person would use. Codes 3 and 4 mean
# different things depending on who sent them, so they get a side each
# (a GX gateway's per-station kick arrives as 8, for instance).
REASONS = {
1: "unspecified",
2: "previous authentication no longer valid",
5: "the AP is full",
6: "class 2 frame from an unauthenticated station",
7: "class 3 frame from an unassociated station",
9: "not authenticated",
14: "MIC failure",
15: "4-way handshake timeout",
16: "group key handshake timeout",
17: "IE mismatch in the 4-way handshake",
23: "802.1X authentication failed",
34: "poor channel conditions",
39: "timeout",
}
AP_REASONS = {3: "the AP is leaving", 4: "inactivity", 8: "the AP is leaving the BSS"}
LOCAL_REASONS = {3: "leaving", 4: "beacon loss", 8: "leaving the BSS"}
def reason_text(code, by_ap):
"""'reason 2: previous authentication no longer valid'. Only our own
words, never the tool's: nothing from the wire reaches the shell."""
side = AP_REASONS if by_ap else LOCAL_REASONS
text = side.get(code) or REASONS.get(code)
return "reason %d: %s" % (code, text) if text else "reason %d" % code
class NlEvents(threading.Thread):
"""Runs `iw event` forever, restarting it if it dies, keeping the last
few deauth/disassoc frames and when the next authentication followed.
Like the probes, this never raises into the daemon: without `iw`, or
when the socket is refused, the thread backs off and `cause_for`
answers None — and the link log falls back to plain "Roamed to".
"""
MAX_CAUSES = 64
LINE_CAP = 1024
def __init__(self):
super().__init__(name="nl-events", daemon=True)
self._stop = threading.Event()
self._proc = None
self._lock = threading.Lock()
self._causes = deque(maxlen=self.MAX_CAUSES)
def stop(self):
self._stop.set()
proc = self._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except OSError:
pass
def run(self):
backoff = 1.0
while not self._stop.is_set():
if not shutil.which("iw"):
time.sleep(min(backoff, 30.0))
backoff = min(backoff * 2, 30.0)
continue
try:
self._run_once()
except Exception:
pass # a spawn or parse failure is a retry, not a crash
if not self._stop.is_set():
# `iw event` never exits on its own; if it did, nl80211
# refused us or the tool is broken — do not spin on it.
time.sleep(min(backoff, 30.0))
backoff = min(backoff * 2, 30.0)
def _run_once(self):
self._proc = subprocess.Popen(
["iw", "event", "-t"], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, errors="replace", bufsize=1,
)
try:
while not self._stop.is_set():
# A capped read: an over-long line comes back in pieces
# that match nothing, instead of growing a buffer.
line = self._proc.stdout.readline(self.LINE_CAP)
if not line:
break
self.consume(line)
finally:
proc, self._proc = self._proc, None
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
# Would not go quietly: do not leave it running.
try:
proc.kill()
proc.wait(timeout=2)
except (OSError, subprocess.TimeoutExpired):
pass
except OSError:
pass
def consume(self, line: str, now: float = None):
m = RE_FRAME.match(line)
if m:
t = float(m.group(1)) if m.group(1) else (now or time.time())
with self._lock:
self._causes.append({
"t": t, "frame": m.group(2),
"sa": m.group(3).lower(), "da": m.group(4).lower(),
"reason": min(int(m.group(5)), 65535), "next_at": None,
})
return
m = RE_NEXT.match(line)
if m:
t = float(m.group(1)) if m.group(1) else (now or time.time())
with self._lock:
# Only the newest cause is still waiting for its follow-up.
if self._causes and self._causes[-1]["next_at"] is None:
self._causes[-1]["next_at"] = t
def cause_for(self, bssid: str, now: float, window: float):
"""The latest frame within `window` seconds that ended our
association with `bssid`, or None.
by_ap: the AP sent it. gap_s: seconds until the next authentication
was seen, None if none has been yet.
"""
bssid = (bssid or "").lower()
if not bssid:
return None
with self._lock:
for c in reversed(self._causes):
if now - c["t"] > window:
break
if bssid not in (c["sa"], c["da"]):
continue
gap = None if c["next_at"] is None else max(0.0, c["next_at"] - c["t"])
return {"t": c["t"], "frame": c["frame"], "by_ap": c["sa"] == bssid,
"reason": c["reason"], "gap_s": gap}
return None
@@ -0,0 +1,372 @@
"""Reading the local end of the connection: route, interface, Wi-Fi link.
Everything here is a cheap read of /sys or a short-lived `ip` / `iw` call.
Nothing in this module blocks for longer than its subprocess timeout, and
every function degrades to None or {} rather than raising, because a laptop
that just suspended will fail all of them at once.
"""
import json
import ipaddress
import re
import shutil
import subprocess
import time
from typing import Optional
def _run(cmd, timeout=2.0) -> Optional[str]:
if not shutil.which(cmd[0]):
return None
try:
out = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=False
)
except (subprocess.TimeoutExpired, OSError):
return None
return out.stdout if out.returncode == 0 else None
# Gateway ranges that phone and desktop tethering hand out. Each is fixed by
# its vendor and documented, so this is a table lookup with no network call
# and nothing to keep up to date. It is the only reliable signal available:
# iOS randomises both the hotspot BSSID and the gateway's hardware address
# (checked on a live hotspot — `66:f8:f9:…` and `a2:ee:1a:…`, both with the
# locally-administered bit set), so vendor lookup on either is useless, and
# NetworkManager reports such a connection as "no (guessed)" rather than
# metered.
TETHER_RANGES = (
# iOS Personal Hotspot, over Wi-Fi or USB. A /28 with the phone at .1.
("172.20.10.0/28", "ios", "iPhone"),
# Android Wi-Fi tethering, and its USB counterpart.
("192.168.43.0/24", "android", "Phone"),
("192.168.42.0/24", "android", "Phone"),
# Windows mobile hotspot.
("192.168.137.0/24", "windows", "Hotspot"),
)
def tether_from_gateway(gateway: str):
"""Is this gateway a phone sharing its connection? Pure, so it is tested.
Returns `{"kind", "label"}` or None. `label` is what to call the middle
node of the path — it is a phone, not a router, and drawing a router
there quietly mislabels both legs.
"""
if not gateway:
return None
try:
addr = ipaddress.ip_address(gateway)
except ValueError:
return None
for cidr, kind, label in TETHER_RANGES:
try:
if addr in ipaddress.ip_network(cidr):
return {"kind": kind, "label": label}
except ValueError:
continue
return None
def nm_metered(iface: str) -> bool:
"""Has the user explicitly marked this connection metered?
Only an explicit answer counts. NetworkManager guesses by default and
guesses wrong on a phone hotspot — a live one reports
`no (guessed)` — so a guess is treated as no answer at all rather than
as evidence either way.
"""
if not iface:
return False
# No binary check here: _run already answers None when nmcli is absent,
# and a second check in front of it kept the parser out of reach of the
# tests on a machine without NetworkManager — which is what CI is.
raw = _run(["nmcli", "-t", "-f", "GENERAL.METERED", "dev", "show", iface],
timeout=4.0)
if not raw:
return False
value = raw.split(":", 1)[-1].strip().lower() if ":" in raw else ""
return value.startswith("yes") and "guess" not in value
def route_to(anchor: str = "1.1.1.1") -> dict:
"""The interface, gateway and source address used to reach the anchor.
This is the single source of truth for "which connection am I on" — the
gateway it returns is the router leg's ping target.
"""
raw = _run(["ip", "-j", "route", "get", anchor])
if not raw:
return {}
try:
rows = json.loads(raw)
except ValueError:
return {}
if not rows:
return {}
r = rows[0]
return {
"iface": r.get("dev") or "",
"gateway": r.get("gateway") or "",
"src": r.get("prefsrc") or "",
}
def is_wireless(iface: str) -> bool:
if not iface:
return False
from pathlib import Path
return Path(f"/sys/class/net/{iface}/wireless").is_dir()
def counters(iface: str) -> Optional[tuple]:
"""(rx_bytes, tx_bytes) straight off /sys, or None if the iface vanished."""
if not iface:
return None
try:
base = f"/sys/class/net/{iface}/statistics/"
with open(base + "rx_bytes") as f:
rx = int(f.read().strip())
with open(base + "tx_bytes") as f:
tx = int(f.read().strip())
except (OSError, ValueError):
return None
return rx, tx
def _num(text: str):
"""First number in a string, as int when it is whole.
`iw` is inconsistent across versions — this machine reports
`freq: 5180.0` where older builds print `freq: 5180`.
"""
m = re.search(r"-?\d+(?:\.\d+)?", text or "")
if not m:
return None
v = float(m.group(0))
return int(v) if v.is_integer() else v
def wifi_link(iface: str) -> dict:
"""SSID, signal, band and negotiated rates from `iw dev <iface> link`."""
raw = _run(["iw", "dev", iface, "link"])
if not raw or "Not connected" in raw:
return {}
info = {}
bssid = re.search(r"Connected to ([0-9a-fA-F:]{17})", raw)
if bssid:
info["bssid"] = bssid.group(1)
for line in raw.splitlines():
line = line.strip()
if line.startswith("SSID:"):
info["ssid"] = line.split(":", 1)[1].strip()
elif line.startswith("freq:"):
info["freq_mhz"] = _num(line)
elif line.startswith("signal:"):
info["signal_dbm"] = _num(line)
elif line.startswith("rx bitrate:"):
info["rx_mbps"] = _num(line)
elif line.startswith("tx bitrate:"):
info["tx_mbps"] = _num(line)
width = re.search(r"(\d+)MHz", line)
if width:
info["width_mhz"] = int(width.group(1))
for std, tag in (("HE", "802.11ax"), ("VHT", "802.11ac"), ("HT", "802.11n")):
if f"{std}-MCS" in line:
info["standard"] = tag
break
freq = info.get("freq_mhz")
if freq:
info["band"] = "6 GHz" if freq >= 5955 else "5 GHz" if freq >= 4900 else "2.4 GHz"
info["channel"] = _freq_to_channel(freq)
return info
def _freq_to_channel(freq) -> Optional[int]:
f = int(freq)
if f == 2484:
return 14
if 2412 <= f <= 2472:
return (f - 2407) // 5
if 5160 <= f <= 5885:
return (f - 5000) // 5
if 5955 <= f <= 7115:
return (f - 5950) // 5
return None
def wifi_station(iface: str) -> dict:
"""Airtime health from `iw station dump`.
Retries and failures are why Wi-Fi feels slow while the signal bar still
looks full, so they are worth the extra call.
"""
raw = _run(["iw", "dev", iface, "station", "dump"])
if not raw:
return {}
fields = {
"tx retries:": "tx_retries",
"tx failed:": "tx_failed",
"beacon loss:": "beacon_loss",
"rx drop misc:": "rx_drop_misc",
"tx packets:": "tx_packets",
"rx packets:": "rx_packets",
"signal avg:": "signal_avg_dbm",
"inactive time:": "inactive_ms",
}
out = {}
for line in raw.splitlines():
s = line.strip()
for prefix, key in fields.items():
if s.startswith(prefix):
v = _num(s[len(prefix):])
if v is not None:
out[key] = v
break
return out
def connection_name(iface: str) -> str:
"""The name NetworkManager shows, which is what the user calls this network."""
raw = _run(["nmcli", "-t", "-f", "GENERAL.CONNECTION", "dev", "show", iface])
if not raw:
return ""
for line in raw.splitlines():
if line.startswith("GENERAL.CONNECTION:"):
name = line.split(":", 1)[1].strip()
return "" if name in ("", "--") else name
return ""
# The connection's name is a NetworkManager fact: it changes when the
# route does, or when the user renames it, and nmcli is a D-Bus client
# that costs ~27 ms per call on this laptop — three quarters of what a
# whole snapshot cost when it was asked twice a second. The name is
# cached per (interface, gateway, BSSID) and re-asked on that key
# changing or every NAME_CACHE_TTL_S, whichever comes first.
NAME_CACHE_TTL_S = 60.0
_name_cache = {} # iface -> (key, name, expires_at)
def connection_name_cached(iface: str, key, now: float = None,
ttl: float = NAME_CACHE_TTL_S) -> str:
now = time.time() if now is None else now
hit = _name_cache.get(iface)
if hit and hit[0] == key and now < hit[2]:
return hit[1]
name = connection_name(iface)
_name_cache[iface] = (key, name, now + ttl)
return name
def snapshot(anchor: str = "1.1.1.1") -> dict:
"""Everything about the local end, in one call, safe to run twice a second."""
route = route_to(anchor)
iface = route.get("iface", "")
snap = {
"iface": iface,
"gateway": route.get("gateway", ""),
"src": route.get("src", ""),
"kind": "none",
}
if not iface:
return snap
snap["kind"] = "wifi" if is_wireless(iface) else "ethernet"
if snap["kind"] == "wifi":
snap.update(wifi_link(iface))
snap["station"] = wifi_station(iface)
key = (iface, snap["gateway"], snap.get("bssid", ""))
snap["name"] = connection_name_cached(iface, key) or iface
return snap
# ------------------------------------------------------------- wan address
TRACE_URL = "https://speed.cloudflare.com/cdn-cgi/trace"
def parse_trace(text: str) -> Optional[dict]:
"""The address, country and edge from a cdn-cgi/trace response.
Every value is validated against its own shape and anything else the
response carries is discarded unread — an address `ipaddress` accepts,
a two-letter country, a short alphabetic colo code. Nothing that fails
its check is passed on, so no free text from the wire reaches the
shell. Input is bounded before it is split, so an oversized body costs
one slice.
The country and edge come free: they are already in the response the
reachability check fetches every hour. Reading them adds no request
and no new destination, which is the whole reason they are here rather
than from a geolocation service that would learn every user's address.
The edge is Cloudflare's, not the user's — it says which datacentre
answered, so present it as provenance and never as a location.
"""
out = {}
for line in text[:4096].splitlines()[:64]:
if line.startswith("ip="):
try:
addr = ipaddress.ip_address(line[3:].strip())
except ValueError:
return None
out["ip"] = str(addr)
out["family"] = "v6" if addr.version == 6 else "v4"
elif line.startswith("loc="):
# Cloudflare answers XX when it does not know, which is not a
# country and must not be shown as one.
code = line[4:].strip()
if len(code) == 2 and code.isascii() and code.isalpha() \
and code.isupper() and code != "XX":
out["country"] = code
elif line.startswith("colo="):
edge = line[5:].strip()
if 2 <= len(edge) <= 5 and edge.isascii() and edge.isalpha() \
and edge.isupper():
out["edge"] = edge
# The address is the point; country and edge are decoration on it.
return out if "ip" in out else None
def trace_verdict(raw) -> str:
"""Did the real internet answer? `open` | `intercepted` | `silent`.
The same fetch that reads the WAN address is also the only thing here
that can tell the real internet from something standing in for it. A
probe reply proves a packet came back; it does not prove what sent it.
A captive portal, a transparent proxy or any middlebox will happily
complete a handshake and answer for an address it does not own — which
is how an unauthenticated hotel network produced a healthy-looking
internet leg with no internet behind it.
`open` is the only positive claim, and it needs the response to parse as
a trace with an address `ipaddress` accepts. Something that answered with
anything else is `intercepted`. Nothing at all is `silent`.
Honest limit: the fetch runs `curl -f`, so a portal that answers with a
4xx/5xx, a redirect with an empty body, or a certificate that does not
validate for the host all come back as nothing — `silent`. In practice
`intercepted` needs a portal that serves a 200 over a certificate valid
for speed.cloudflare.com, which is rare. The captive decision does not
care (both verdicts count against `open`); only this label does.
"""
if not raw:
return "silent"
return "open" if parse_trace(raw) else "intercepted"
def reachability() -> dict:
"""One reachability check: the verdict, plus the address when proven.
This is also where the WAN address comes from — the `proof` — so the
machine's address is only ever asked of a host the daemon talks to
anyway, never of an ifconfig-style third party. The URL is our
constant, never anything a response handed us. Cadence is the caller's
(CaptiveWatch): on every new network, hourly once the internet has
answered, every 30 s only while it has not.
"""
raw = _run(["curl", "-sf", "--proto", "=https", "--max-time", "5",
"--max-filesize", "4096", TRACE_URL], timeout=8.0)
verdict = trace_verdict(raw)
return {"verdict": verdict,
"proof": parse_trace(raw) if verdict == "open" else None}
@@ -0,0 +1,91 @@
"""Where Nexthop keeps its state.
Two directories since 0.2.22. The state dir (XDG_STATE_HOME, like the rest
of Omarchy) holds what must outlive the session: history.db, the
config.json the panel writes for the daemon, and the lock so two daemons
never fight. The runtime dir (XDG_RUNTIME_DIR, a per-user tmpfs) holds the
three snapshots the daemon rewrites continuously — live.json twice a
second, apps.json every three, recent.json every five. They are derived,
session-scoped, and worth nothing after a reboot, and on this laptop's
btrfs each 2.5 KB rewrite cost 62.5 KiB at the block layer with fsync:
14.65 GB a day for a bar widget, measured from the kernel's own counter.
On tmpfs it is nothing. Without XDG_RUNTIME_DIR they fall back to the
state dir, so reader and writer always agree in one session environment.
"""
import os
from pathlib import Path
def state_dir() -> Path:
base = os.environ.get("XDG_STATE_HOME") or os.path.join(Path.home(), ".local", "state")
return Path(base) / "nexthop"
def ensure_state_dir() -> Path:
"""Create the state dir, private to the user (0700).
The files inside carry the daemon's pid and the version string the
shell service uses to authorize a SIGTERM — nothing another account
has any business reading, let alone writing.
"""
d = state_dir()
d.mkdir(parents=True, exist_ok=True)
try:
d.chmod(0o700)
except OSError:
pass
return d
def runtime_dir() -> Path:
"""The volatile snapshots' home: the session's tmpfs, else the state dir."""
base = os.environ.get("XDG_RUNTIME_DIR")
if base and os.path.isdir(base):
return Path(base) / "nexthop"
return state_dir()
def ensure_runtime_dir() -> Path:
"""Create the runtime dir, private to the user like the state dir."""
d = runtime_dir()
d.mkdir(parents=True, exist_ok=True, mode=0o700)
try:
d.chmod(0o700)
except OSError:
pass
return d
LIVE = "live.json"
RECENT = "recent.json"
APPS = "apps.json"
DB = "history.db"
LOCK = "nexthopd.lock"
def live_path() -> Path:
return runtime_dir() / LIVE
def recent_path() -> Path:
return runtime_dir() / RECENT
def apps_path() -> Path:
return runtime_dir() / APPS
def manifest_path() -> Path:
"""The plugin's own manifest, beside this package rather than in the
state dir — the shell service reads it to spot a fast-forwarded
checkout."""
return Path(__file__).resolve().parent.parent / "manifest.json"
def db_path() -> Path:
return state_dir() / DB
def lock_path() -> Path:
return state_dir() / LOCK
@@ -0,0 +1,484 @@
"""Persistent ping and TCP-handshake probes, and the rolling windows they feed.
One long-lived `ping` process per target rather than one process per sample.
At two samples a second, spawning a process each time would mean 172,800
forks a day inside a laptop's idle budget; `ping -i` already does the timing
for us, and `-O` makes it say so out loud when a packet goes missing.
"""
import collections
import re
import shutil
import socket
import statistics
import subprocess
import threading
import time
from collections import deque
def nearest_rank(ordered, p: float):
"""Nearest-rank percentile of a NON-EMPTY sorted sequence.
One implementation shared by the probe stats, the per-app socket stats
and the speed baseline, so the three cannot drift apart — they read the
same figure off the same rule. The index formula already collapses to
element 0 for a single-element input, so no length special-case.
"""
i = min(len(ordered) - 1, max(0, int(round(p * (len(ordered) - 1)))))
return ordered[i]
# [1787562260.703963] 64 bytes from 10.10.0.1: icmp_seq=1 ttl=64 time=9.13 ms
RE_REPLY = re.compile(r"^\[(\d+\.\d+)\].*icmp_seq=(\d+).*time=([\d.]+)\s*ms")
# [1787562369.690501] no answer yet for icmp_seq=1
RE_PENDING = re.compile(r"^\[(\d+\.\d+)\]\s+no answer yet for icmp_seq=(\d+)")
# [...] From 10.10.0.147 icmp_seq=1 Destination Host Unreachable
RE_UNREACH = re.compile(r"^\[(\d+\.\d+)\].*icmp_seq=(\d+).*(?:Unreachable|unreachable)")
# How much recent history judges an instrument, and the fewest samples that
# can carry a judgement at all.
#
# Shared, deliberately, by the two places that ask "what has this instrument
# been doing lately": the bench, which ranks instruments over a window, and
# TcpProbe, which needs its own recent p50 to tell a retransmit from a slow
# path. They live here rather than on the bench because `instruments` imports
# this module and not the other way round, and an alias in the direction the
# imports already run is the only one Python will take.
#
# The point of aliasing rather than repeating the number: two literals plus a
# test catches drift on the next test run, an alias makes the drift
# impossible. `test_the_window_matches_what_the_bench_ranks_on` is kept even
# though it now passes by construction — it catches someone replacing an
# alias with a literal, which is the drift it was written against.
RECENT_WINDOW_S = 300.0
RECENT_MIN_SAMPLES = 8
class Series:
"""A rolling window of (timestamp, rtt_ms or None) for one target.
None means the probe went out and nothing came back. Keeping losses in
the same series as the replies is what lets a single pass compute both
latency and loss over any sub-window.
"""
def __init__(self, window_s: float = 1830.0):
self.window_s = window_s
self._samples = deque()
self._lock = threading.Lock()
def add(self, t: float, rtt, loaded: bool = False):
"""Record one probe result, tagged with whether the link was busy.
The tag is what makes bufferbloat visible: the same connection can
answer in 15 ms while idle and 300 ms while a download runs, and a
score built only on the idle number calls that line excellent right
up until someone uses it.
"""
with self._lock:
self._samples.append((t, rtt, bool(loaded)))
cutoff = t - self.window_s
while self._samples and self._samples[0][0] < cutoff:
self._samples.popleft()
def since(self, seconds: float):
cutoff = time.time() - seconds
with self._lock:
return [s for s in self._samples if s[0] >= cutoff]
def all(self):
with self._lock:
return list(self._samples)
@staticmethod
def split_by_load(samples):
"""(idle, loaded) — probes taken while the link was quiet vs busy.
Samples are indexed rather than unpacked throughout, so a caller
holding older two-element samples still reads as idle instead of
raising.
"""
idle = [s for s in samples if not (len(s) > 2 and s[2])]
loaded = [s for s in samples if len(s) > 2 and s[2]]
return idle, loaded
@staticmethod
def stats(samples) -> dict:
"""Latency percentiles, jitter and loss over the samples given.
Jitter is mean absolute difference between consecutive replies
(RFC 3550's IPDV), not standard deviation: a connection that
alternates 10/40/10/40 ms feels far worse than one that drifts
smoothly across the same range, and only IPDV says so.
"""
total = len(samples)
if total == 0:
return {"count": 0, "loss": None, "p50": None, "p75": None,
"p95": None, "jitter": None, "last": None, "max": None}
rtts = [s[1] for s in samples if s[1] is not None]
lost = total - len(rtts)
loss = lost / total
if not rtts:
return {"count": total, "loss": loss, "p50": None, "p75": None,
"p95": None, "jitter": None, "last": None, "max": None}
ordered = sorted(rtts)
deltas = [abs(rtts[i] - rtts[i - 1]) for i in range(1, len(rtts))]
last = next((x[1] for x in reversed(samples) if x[1] is not None), None)
return {
"count": total,
"loss": loss,
"p50": round(statistics.median(ordered), 2),
"p75": round(nearest_rank(ordered, 0.75), 2),
"p95": round(nearest_rank(ordered, 0.95), 2),
"max": round(ordered[-1], 2),
"jitter": round(statistics.fmean(deltas), 2) if deltas else 0.0,
"last": round(last, 2) if last is not None else None,
}
class PingProbe(threading.Thread):
"""Runs one `ping` forever, restarting it if it dies, feeding a Series.
A probe never raises into the daemon: if `ping` is missing, the target
stops resolving, or the interface goes away, the thread backs off and
keeps trying while the series simply records losses.
"""
def __init__(self, target: str, series: Series, interval_ms: int = 500,
name: str = "", loaded_fn=None):
super().__init__(name=f"probe-{name or target}", daemon=True)
self.target = target
self.series = series
# Asked at the moment a sample lands, so each probe is tagged with
# the link state it actually experienced rather than whatever the
# link was doing when the window is later read.
self.loaded_fn = loaded_fn
self.interval = max(0.2, interval_ms / 1000.0)
self._stop = threading.Event()
self._proc = None
# seq -> timestamp first seen unanswered, drained by _expire()
self._pending = {}
# seq -> when it was charged as lost. A packet the grace period gave
# up on can still be reported afterwards — the gateway's Destination
# Host Unreachable for it arrives later than the grace, in the real
# recording by 0.35 s — and without this it would be charged twice.
# Held for one further grace period, which is as long as a late report
# can be believed to belong to that packet.
self._charged = {}
def _loaded(self) -> bool:
try:
return bool(self.loaded_fn()) if self.loaded_fn else False
except Exception:
return False # a probe never raises into the daemon
def stop(self):
self._stop.set()
proc = self._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except OSError:
pass
def set_interval(self, seconds: float):
"""Change cadence in place — a benched instrument idles, a seated
one probes at full rate, without tearing the thread down. `ping`
takes its interval on the command line, so the running process is
retired and the run loop respawns it with the new one."""
seconds = max(0.2, float(seconds))
if abs(seconds - self.interval) < 1e-9:
return
self.interval = seconds
proc = self._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except OSError:
pass
def _reset_tracking(self):
"""Forget both maps together.
`ping` numbers from 1 again on every respawn, so a seq remembered past
the process that produced it would suppress a real loss on the next
one — turning a guard against overcharging into an undercount, which
is the same defect facing the other way.
"""
self._pending.clear()
self._charged.clear()
def _expire(self, now: float):
"""A packet still unanswered after the grace period is a lost packet.
`ping -O` reports "no answer yet" as soon as it sends the next probe,
but a slow reply can still land, so a pending seq is only counted as
lost once it is too old to come back.
"""
grace = self.interval * 2.5 + 1.0
for seq, t in list(self._pending.items()):
if now - t > grace:
del self._pending[seq]
self._charged[seq] = now
self.series.add(t, None, self._loaded())
# Bounded by the same clock that fills it: a seq stops being
# remembered once no report about it could still arrive.
for seq, t in list(self._charged.items()):
if now - t > grace:
del self._charged[seq]
def run(self):
backoff = 1.0
while not self._stop.is_set():
if not shutil.which("ping") or not self.target:
time.sleep(min(backoff, 30.0))
backoff = min(backoff * 2, 30.0)
continue
try:
self._run_once()
backoff = 1.0
except Exception:
# Never let a parse or spawn failure take the daemon with it.
time.sleep(min(backoff, 30.0))
backoff = min(backoff * 2, 30.0)
def _run_once(self):
cmd = ["ping", "-n", "-O", "-D", "-i", f"{self.interval:g}",
"-W", "1", self.target]
self._reset_tracking()
self._proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1,
)
try:
for line in self._proc.stdout:
if self._stop.is_set():
break
self._consume(line)
# ping exited: whatever was outstanding never arrived.
for seq, t in self._pending.items():
self.series.add(t, None, self._loaded())
self._reset_tracking()
finally:
proc, self._proc = self._proc, None
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
# Would not go quietly: do not leave it running.
try:
proc.kill()
proc.wait(timeout=2)
except (OSError, subprocess.TimeoutExpired):
pass
except OSError:
pass
def _consume(self, line: str):
m = RE_REPLY.match(line)
if m:
t, seq, rtt = float(m.group(1)), int(m.group(2)), float(m.group(3))
self._pending.pop(seq, None)
# A reply this late cannot un-lose the packet — the window it
# belonged to has already been read — and recording the RTT as
# well would put two samples on the wire's one packet.
if seq not in self._charged:
self.series.add(t, rtt, self._loaded())
self._expire(t)
return
m = RE_UNREACH.match(line)
if m:
t, seq = float(m.group(1)), int(m.group(2))
self._pending.pop(seq, None)
if seq not in self._charged:
self.series.add(t, None, self._loaded())
self._expire(t)
return
m = RE_PENDING.match(line)
if m:
t, seq = float(m.group(1)), int(m.group(2))
# `ping -O` repeats "no answer yet" for the same seq, so one that
# has already been charged must not be put back on the pending
# list to be charged a second time.
if seq not in self._charged:
self._pending.setdefault(seq, t)
self._expire(t)
class TcpProbe(threading.Thread):
"""Connect-time RTT to the anchor's TLS port, feeding a Series.
ICMP measures what routers choose to answer, and they answer it fast:
many devices handle it in hardware, in an ASIC or via XDP, while real
traffic waits in the user-space path behind the queues that actually
hold it up. Anything on the way can also reply on the destination's
behalf, because there is nothing in ICMP to prove otherwise.
A TCP handshake cannot be shortcut that way. The SYN has to reach a
listener that completes it, over port 443 where the user's own traffic
goes, so its round trip is the one applications experience. One
connection per sample, opened and closed — no payload, no TLS, nothing
kept.
Since 0.2.0 these are seated instruments in the bench (instruments.py):
the two best of four feed the scored internet leg, so a TCP series moves
the score whenever it holds a seat. The anchor's ICMP figure is still
recorded beside it per minute (`lag_icmp`) so the switch stays auditable.
"""
CONNECT_TIMEOUT_S = 2.0
# Linux and macOS both start TCP's retransmit timer at one second, so a
# handshake that comes back at or past this did not measure a slow path:
# its SYN was dropped and the kernel sent another. The number is the
# kernel's constant, not the network's round trip, and folding it into a
# latency percentile reports the line as slow when what happened is that
# a packet was lost.
#
# The connect timeout was already drawing this line, in the wrong place
# and for the wrong reason: a handshake needing TWO retransmits waits
# 1 s + 2 s, exceeds CONNECT_TIMEOUT_S and is recorded as loss, while one
# needing a single retransmit returns at ~1 s and was recorded as a round
# trip. The same event, accounted two opposite ways, with the boundary
# wherever the timeout happened to fall.
# One initial RTO, with slop for timer granularity and scheduling. The
# first retransmit fires at 1000 ms on Linux, macOS and Windows alike.
RETRANSMIT_MARGIN_MS = 900.0
# The baseline is this instrument's own recent p50 over the shared window
# above — aliases, not copies, so "recent" cannot come to mean two things.
RETRANSMIT_WINDOW_S = RECENT_WINDOW_S
RETRANSMIT_MIN_SAMPLES = RECENT_MIN_SAMPLES
def __init__(self, target: str, series: Series, interval_s: float = 1.0,
name: str = "", loaded_fn=None, port: int = 443):
super().__init__(name=f"tcp-{name or target}", daemon=True)
self.target = target
self.port = port
self.series = series
self.interval = max(0.25, interval_s)
self.loaded_fn = loaded_fn
self._stop = threading.Event()
self.ever_connected = False
# The recent round trips this probe has actually seen, for the
# comparison above. Bounded, and its own — the series it feeds is
# merged with other instruments and cannot answer "what does THIS
# path usually do". Held as (when, rtt) so the window is a duration
# rather than a count, which is what makes it survive a cadence
# change: a benched instrument probes at a fraction of the rate.
self._recent = collections.deque(maxlen=1024)
# Samples this probe declined to call latency, so the reclassification
# can be seen rather than inferred from a loss rate. A dropped SYN and
# a slow line are different faults with different owners.
self.retransmits = 0
self.unclassified = 0
def stop(self):
self._stop.set()
def set_interval(self, seconds: float):
"""Picked up on the next cycle; nothing to tear down here."""
self.interval = max(0.25, float(seconds))
def _loaded(self) -> bool:
try:
return bool(self.loaded_fn()) if self.loaded_fn else False
except Exception:
return False
def _once(self):
started = time.time()
t0 = time.monotonic()
try:
sock = socket.create_connection((self.target, self.port),
timeout=self.CONNECT_TIMEOUT_S)
except (OSError, ValueError):
self.series.add(started, None, self._loaded())
return
rtt = (time.monotonic() - t0) * 1000.0
try:
sock.close()
except OSError:
pass
# The handshake completed, so the target is reachable, whatever the
# kernel had to do to get there.
self.ever_connected = True
verdict = self._classify(started, rtt)
if verdict == "retransmit":
# Loss on new connections, which is what it is. Recorded the same
# way a refused or timed-out connect already is, so it charges the
# loss term and Reliability rather than the latency percentiles.
self.retransmits += 1
self.series.add(started, None, self._loaded())
return
if verdict == "unknown":
# Past the floor before this probe has a baseline to judge it
# against. It is either a retransmit or a genuinely slow path and
# nothing here can tell which, so it is not recorded as either —
# inventing a loss and publishing a suspect latency are both
# claims, and the honest move is to make neither.
#
# It still feeds the baseline, and that is not an oversight. A
# link whose real round trip is past the floor — p50 1200 ms, say
# — has every sample land here, so a deque that only accepted
# classified samples would never reach its minimum, the baseline
# would never form, and 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. A silent unrankable instrument, invisible because
# it is not failing, merely absent.
self.unclassified += 1
self._recent.append((started, rtt))
return
self._recent.append((started, rtt))
self.series.add(started, round(rtt, 2), self._loaded())
def _baseline_ms(self, now: float):
"""This instrument's own recent p50, or None while it has too few."""
cutoff = now - self.RETRANSMIT_WINDOW_S
recent = [rtt for t, rtt in self._recent if t >= cutoff]
if len(recent) < self.RETRANSMIT_MIN_SAMPLES:
return None
return statistics.median(recent)
def _classify(self, now: float, rtt_ms: float) -> str:
""""reply", "retransmit" or "unknown" for a handshake that completed.
A connect rescued by a retransmitted SYN is a lost packet, not a slow
path, and the threshold has to be relative or it mislabels distance as
loss: one full RTO ABOVE what this instrument usually sees. A satellite
link whose p50 is 600 ms gets a threshold of 1500, so a 1045 ms sample
there stays the measurement it is.
"""
if rtt_ms < self.RETRANSMIT_MARGIN_MS:
return "reply"
baseline = self._baseline_ms(now)
if baseline is None:
return "unknown"
# Note what does NOT reach the baseline once one exists: a sample this
# returns "retransmit" for. Feeding those back would raise the
# threshold on the instrument's own retransmits and the rule would
# quietly stop firing exactly where it is needed most.
return "retransmit" if rtt_ms >= baseline + self.RETRANSMIT_MARGIN_MS \
else "reply"
def run(self):
while not self._stop.is_set():
if not self.target:
self._stop.wait(5.0)
continue
t0 = time.monotonic()
try:
self._once()
except Exception:
# Never let a socket or DNS failure take the daemon with it.
pass
self._stop.wait(max(0.0, self.interval - (time.monotonic() - t0)))
@@ -0,0 +1,553 @@
"""Turning measurements into the three component scores and the index.
The shape follows Orb: Responsiveness, Reliability and Speed each score
0-100, and the index is the weakest of them with a nudge from the other two
(`index`, 0.1.10 — a plain mean let one dead dimension hide behind two good
ones). Ranking a connection by its download number alone is exactly the
habit that leaves people with a fast line that feels broken on a video call.
Every threshold below is an anchor table rather than a formula. Anchors are
arguable in public, which is the point: someone who disagrees that 60 ms of
lag is a 78 can say so about one number instead of reverse-engineering a
curve.
"""
# Lag in ms -> Responsiveness score. Interpolated linearly between anchors.
LAG_ANCHORS = [
(10, 100), (20, 95), (35, 88), (60, 78), (100, 68),
(200, 50), (400, 30), (800, 10), (1500, 0),
]
# Fraction of the plan achieved -> Speed score, used only when the user has
# configured a plan. Deliberately forgiving in the middle: an ISP delivering
# 80% of a sold plan is doing fine, and a score that punished that would cry
# wolf every evening.
SPEED_ANCHORS = [
(0.0, 0), (0.1, 15), (0.25, 35), (0.5, 60),
(0.7, 75), (0.85, 88), (1.0, 96), (1.15, 100),
]
# The default basis: Mbps -> score, anchored to what applications need
# rather than to any plan. Speed has steep diminishing returns — 25 Mbps
# carries a 4K stream, ~100 feels instant for nearly everything, and past
# ~300 a person cannot tell the difference — so the curve saturates.
SPEED_ABS_DOWN = [
(0, 0), (5, 25), (25, 55), (50, 70), (100, 82),
(200, 90), (300, 94), (500, 98), (750, 100),
]
SPEED_ABS_UP = [
(0, 0), (2, 30), (5, 55), (10, 70), (20, 82),
(50, 92), (100, 100),
]
BANDS = [(90, "excellent"), (80, "good"), (70, "okay"), (50, "fair"), (0, "poor")]
def _interp(anchors, x):
if x <= anchors[0][0]:
return float(anchors[0][1])
if x >= anchors[-1][0]:
return float(anchors[-1][1])
for i in range(1, len(anchors)):
x0, y0 = anchors[i - 1]
x1, y1 = anchors[i]
if x <= x1:
span = x1 - x0
return float(y0 + (y1 - y0) * ((x - x0) / span if span else 0))
return float(anchors[-1][1])
# What one lost packet costs, in milliseconds. It is a retransmit timeout,
# so it scales with the link's own round trip rather than being a constant:
# RTO is roughly SRTT + 4·RTTVAR, and nothing recovers faster than Linux's
# 200 ms floor. The stall factor is on top, because a drop costs more than
# the resent packet — everything behind it waits (head-of-line blocking)
# and the congestion window has to climb back.
LOSS_RTO_FLOOR_MS = 200.0
LOSS_RTT_MULTIPLIER = 3.0
LOSS_STALL_FACTOR = 5.0
def loss_cost_ms(p75_ms: float) -> float:
"""Milliseconds of felt lag per unit of loss, for a link this fast.
Below ~67 ms p75 this returns 1000, which is exactly the flat constant
it replaces — so ordinary connections score as they always did. Above
it the charge grows with the round trip, which is the part the constant
got wrong: on a 600 ms satellite link a dropped packet does not cost the
same 10 ms per percent that it costs on fibre.
"""
return LOSS_STALL_FACTOR * max(LOSS_RTO_FLOOR_MS,
LOSS_RTT_MULTIPLIER * max(0.0, p75_ms))
def lag_ms(stats: dict):
"""One number for how the connection feels, in milliseconds.
Latency alone under-reports: a link that is 10 ms most of the time but
swings to 90 ms and drops a packet every few seconds feels much worse
than its median suggests. So lag leans on p75 rather than the median,
adds the jitter the user actually perceives, and charges for loss at a
rate that reflects a retransmit round trip on THIS link.
"""
if not stats or stats.get("count", 0) == 0:
return None
loss = stats.get("loss") or 0.0
if stats.get("p75") is None:
# Everything was lost. There is no latency to report, only a verdict.
return None if loss < 1.0 else 1500.0
base = stats["p75"]
jitter = stats.get("jitter") or 0.0
return round(base + 1.5 * jitter + loss * loss_cost_ms(base), 1)
def lag_band(stats: dict) -> dict:
"""Lag at three latency percentiles: best, typical, worst.
All three go through the same fold, differing only in which percentile
they lean on, and that is the whole point. The panel used to pair a
loss-charged "typical" with raw millisecond figures either side of it,
so a lossy link displayed "best 4 · typical 644 · worst 26" — three
numbers that cannot all be true at once, because two were round trips
and one was a composite.
Sharing the fold makes the ordering hold by construction and makes loss
move all three together, which is what a reader assumes a range means.
"""
if not stats or stats.get("count", 0) == 0:
return {"best": None, "typical": None, "worst": None}
if stats.get("p75") is None:
# Everything in the window was lost. `lag_ms` answers 1500 here so
# Responsiveness lands on zero, which is its job — but 1500 is an
# anchor, not a measurement, and the panel used to print it three
# times as though the link were replying slowly. There is no latency
# to display, so display none.
return {"best": None, "typical": None, "worst": None}
out = {}
prev = None
for name, key in (("best", "p50"), ("typical", "p75"), ("worst", "p95")):
v = lag_ms(dict(stats, p75=stats.get(key)))
# p95 can equal p75 on a short window, and a percentile can be
# missing; neither may let the range read backwards.
if v is not None and prev is not None:
v = max(v, prev)
out[name] = v
if v is not None:
prev = v
return out
def responsiveness(lag):
if lag is None:
return 0.0
return round(_interp(LAG_ANCHORS, lag), 1)
RELIABILITY_WINDOW_S = 24 * 3600
# A self-healed interruption is real but not as bad as being down, so its
# time is charged at a discount.
DISRUPTION_TIME_WEIGHT = 0.5
# Each interruption also costs recovery beyond its own length — a dropped
# call is redialled, a stream rebuffers, a download restarts — so every
# event carries this much equivalent disruption. Expressed in SECONDS on
# purpose: a penalty in raw points cannot be compared with downtime, which
# is exactly how the old flat "6 points per disruption" ended up charging a
# brief blip more than an hour offline.
DISRUPTION_RECOVERY_S = 300.0
# Backstop so a pathological count can never dominate the component.
DISRUPTION_MAX_PENALTY = 25.0
def reliability(outage_fraction: float, disruptions: int, covered: bool = True,
disruption_fraction: float = 0.0,
window_s: float = RELIABILITY_WINDOW_S):
"""Uptime, not smoothness — everything charged in one currency: time.
Orb moved reliability to bite only during true outages, and that is the
right call: a wobbly ten minutes is already punished by responsiveness,
and double-counting it made the overall score swing on a single bad
evening. Here an outage is total loss on the wan leg; a disruption is a
shorter interruption that resolved on its own.
Both are now charged by DURATION. They used to be charged in different
currencies — outages by their share of the window, disruptions at a flat
6 points each — and the units did not meet: over a 24 h window one
ten-minute outage cost 0.7 points while three self-healed blips cost 18,
so the milder event was punished twenty-six times harder, and seventeen
blips zeroed the component outright. Time is the honest unit for "how
much of today was this connection unusable", and an event's recovery
cost is expressed in seconds so it lands on the same scale.
"""
if not covered:
return 100.0
window = window_s if window_s and window_s > 0 else RELIABILITY_WINDOW_S
down = max(0.0, min(1.0, outage_fraction))
disrupted_s = (max(0.0, min(1.0, disruption_fraction)) * window
+ max(0, disruptions) * DISRUPTION_RECOVERY_S)
penalty = min(DISRUPTION_MAX_PENALTY,
100.0 * DISRUPTION_TIME_WEIGHT * min(1.0, disrupted_s / window))
score = 100.0 - 100.0 * down - penalty
return round(max(0.0, min(100.0, score)), 1)
def speed_absolute(down_mbps, up_mbps):
"""Is it fast enough — scored against what applications need.
Download is weighted 3:1 over upload. That is not a claim that upload
matters less in general — it is that most lines are asymmetric by
design, so equal weighting would score every ordinary connection as
broken.
"""
if down_mbps is None:
return None
parts = [(_interp(SPEED_ABS_DOWN, down_mbps), 3.0)]
if up_mbps is not None:
parts.append((_interp(SPEED_ABS_UP, up_mbps), 1.0))
weighted = sum(v * w for v, w in parts)
return round(weighted / sum(w for _, w in parts), 1)
# A Speed figure may be reported and still be too thin to set the headline.
# Two samples is the floor because the guard above it is a median: with one
# sample there is no median to take, so the single check IS the verdict —
# and the least trustworthy check of all is the first one after joining a
# network, taken while the link is still settling.
MIN_SPEED_SAMPLES = 2
# A saturating test that read at least this much more than the everyday
# basis has disproved it. Peak stays unscored — a manual test must not
# flatter the score — but it can withdraw a figure it contradicts.
PEAK_CONTRADICTION_RATIO = 2.0
def speed_scored(down_mbps, samples: int, peak_down=None) -> bool:
"""May this Speed figure set the index, or only be displayed?
The index is weakest-link, so whichever component is lowest becomes the
headline. Responsiveness and Reliability are built from thousands of
probes a minute; Speed is one 12 MB sample an hour. Letting the thinnest
input hold a veto is how a healthy 380 Mbps line reported POOR off a
single check taken 55 seconds after associating, while a peak test on
the same line minutes later read four times higher.
So the rule is not new weighting, it is eligibility: a figure that is
under-sampled, or contradicted by a faster measurement of the same line,
is shown with its reason and left out of the index. `index` already
skips a component it does not have rather than inventing one — this
gives it the same honesty for a component we have but do not trust.
"""
if down_mbps is None:
return False
if samples < MIN_SPEED_SAMPLES:
return False
if peak_down and peak_down >= down_mbps * PEAK_CONTRADICTION_RATIO:
return False
return True
def degradation_penalty(down_mbps, baseline_down):
"""Is it normal for this network — a penalty for big drops only.
The baseline is the connection's own recent p90. Sharing an office line
means honest hour-to-hour variance, so nothing below a 40% shortfall
counts; from there the penalty grows to 35 points at zero. This is what
catches "we normally get 300 here and today it is 60" on a line whose
absolute score would still look comfortable.
"""
if down_mbps is None or not baseline_down or baseline_down <= 0:
return 0.0
ratio = down_mbps / baseline_down
if ratio >= 0.6:
return 0.0
return round((0.6 - ratio) / 0.6 * 35.0, 1)
def speed(down_mbps, up_mbps, plan_down=0, plan_up=0, baseline_down=None):
"""The Speed component.
With a configured plan: scored against the plan (ISP accountability —
opt-in, because almost nobody configures a plan and shared office lines
have no meaningful one). Without: the absolute experience curve, minus
the degradation penalty against the connection's own baseline.
"""
if down_mbps is None:
return None
if plan_down and plan_down > 0:
ratios = [(down_mbps / plan_down, 3.0)]
if plan_up and plan_up > 0 and up_mbps is not None:
ratios.append((up_mbps / plan_up, 1.0))
weighted = sum(_interp(SPEED_ANCHORS, r) * w for r, w in ratios)
return round(weighted / sum(w for _, w in ratios), 1)
base = speed_absolute(down_mbps, up_mbps)
if base is None:
return None
return round(max(0.0, base - degradation_penalty(down_mbps, baseline_down)), 1)
# How much of the index the worst component owns. The remainder lets the
# other two nudge it up a little, so "everything else is excellent" still
# reads differently from "everything is mediocre".
INDEX_WORST_WEIGHT = 0.92
def index(resp, rel, spd):
"""Weakest-link, skipping any component we genuinely cannot measure.
A mean let one broken dimension hide behind two good ones: a line
scoring Responsiveness 40, Reliability 100, Speed 95 averaged to 78 and
read as "okay" — while video calls on it did not work. That is exactly
the habit this module exists to avoid, reintroduced at the last step.
People experience the bottleneck, not the average, so the worst
component sets the number and the others only nudge it.
This also puts us where the rest of the field is: Pulse aggregates
weakest-link (validated against a real fleet) and IETF
draft-ietf-ippm-qoo takes a strict minimum. A mean was the outlier.
Scoring an unmeasured component as zero would be a lie; scoring it as
100 would be a different lie. Leaving it out and saying so is honest,
and it means the index is useful within seconds of starting rather than
after the first speed test lands.
"""
parts = [p for p in (resp, rel, spd) if p is not None]
if not parts:
return None
worst = min(parts)
others = list(parts)
others.remove(worst) # by equality: one instance, ties keep the rest
if not others:
return int(round(worst))
rest = sum(others) / len(others)
return int(round(INDEX_WORST_WEIGHT * worst + (1.0 - INDEX_WORST_WEIGHT) * rest))
# The states in which a headline index is not a current reading.
OUTAGE_STATES = ("local-down", "wan-down")
def scored_now(state):
"""Whether an index may stand as the headline in this state.
Not while a leg is confirmed down. Every input to the index describes a
window that mostly predates the outage: Lag reads 30 s that still holds
pre-outage replies, and Reliability charges the downtime against 24 h,
where a minute is 0.07 % and rounds away. Speed is skipped honestly.
Weakest-link over two components that both still read 100 therefore
reports 100 — seen in the wild on a real 61 s Wi-Fi drop, the panel
showing EXPERIENCE 100 directly beneath its own ROUTER UNREACHABLE.
Withheld rather than lowered, because any number chosen here would be
invented, and the state is already the honest headline: the panel draws
the verdict beside it and the bar counts the outage. Same rule as
scoring None rather than fabricating a figure.
A quiet spell is deliberately not an outage. gateway-quiet and
icmp-quiet leave the state calm precisely because traffic is still
crossing the leg, so the index keeps standing there and should.
"""
return state not in OUTAGE_STATES
def band(score):
if score is None:
return "unknown"
for floor, name in BANDS:
if score >= floor:
return name
return "poor"
# Below this the two legs are indistinguishable at our resolution, and a
# near-zero ISP leg is physically possible (an anchor a hop past the
# gateway). Above it, the router answering slower than the internet behind
# it means the two independent distributions disagree and the subtraction is
# void — not that the ISP adds nothing.
WAN_INVERSION_TOLERANCE_MS = 1.0
def wan_point_ms(total_ms, local_ms):
"""The ISP leg for ONE pair of readings, or None when it says nothing.
The same rule `wan_from` applies per statistic, factored out so the
per-point series in recent.json and the per-window statistics cannot
drift apart. A gateway that answers slower than the internet behind it
is common — plenty of them deprioritise ICMP addressed to themselves —
and the subtraction has nothing to say about the line when it happens,
so the answer is None rather than a clamped zero.
"""
if total_ms is None or local_ms is None:
return None
if local_ms > total_ms + WAN_INVERSION_TOLERANCE_MS:
return None
return round(max(0.0, total_ms - local_ms), 2)
def wan_from(total: dict, local: dict) -> dict:
"""The ISP leg: what is left of the round trip once the router's share is gone.
The two probes are not synchronised, so this subtracts distributions
rather than individual packets — p50 from p50, p75 from p75. Loss on the
wan leg is whatever the internet probe lost beyond what the router probe
lost, since loss on the local link shows up in both.
"""
out = {"count": total.get("count", 0)}
prev = 0.0
for key in ("p50", "p75", "p95", "max"):
t, l = total.get(key), local.get(key)
# One refusal, in one place — `wan_point_ms` — for both reasons it
# refuses, because a rule whose whole job is to withhold must not
# have a second copy that can forget to.
#
# It withholds when the router answered SLOWER than the internet
# behind it, because `total = local + wan` does not hold and the
# subtraction has nothing to say. Clamping the negative to zero used
# to report the ISP leg as 0.0 ms — the best possible answer, from an
# invalid measurement, on the number the whole panel is built around.
# It happens for a real reason: plenty of gateways deprioritise or
# rate-limit ICMP addressed to themselves, so their own replies are
# slow while everything they forward is fast. That says something
# about the gateway's control plane, not about the link.
#
# And when either reading is missing: unknown, not zero. Substituting
# 0 for a local statistic we do not have made the derived leg equal
# the whole round trip, so a silent gateway produced a confident,
# healthy-looking internet figure that was really the total wearing
# the wan leg's label.
v = wan_point_ms(t, l)
if v is None:
out[key] = None
continue
# Subtracting two independent distributions statistic-by-statistic
# can invert the order (a wan p95 below the wan p50) when the local
# leg's tail is fatter than the total's. Each statistic is floored
# at the one before it so the derived leg reads like a distribution.
# The floor has to carry forward FLOORED, not raw: `prev` is what the
# previous statistic ended up reporting, so a p95 that subtracts lower
# than the p50 still reads as a distribution.
v = max(prev, v)
out[key] = round(v, 2)
prev = v
t, l = total.get("last"), local.get("last")
if t is None or (l is not None and l > t + WAN_INVERSION_TOLERANCE_MS):
out["last"] = None
else:
out["last"] = round(max(0.0, t - (l or 0.0)), 2)
# Jitter does not subtract: variance on the local link propagates into
# the total, so the honest reading is "no less than the total's jitter
# minus the local's", floored at zero.
tj, lj = total.get("jitter"), local.get("jitter")
out["jitter"] = None if tj is None else round(max(0.0, tj - (lj or 0.0)), 2)
tl, ll = total.get("loss"), local.get("loss")
out["loss"] = None if tl is None else max(0.0, tl - (ll or 0.0))
return out
# How close to the idle baseline counts as drained. A queue does not empty
# to the exact millisecond it started from, and demanding that would report
# "never recovered" on a link that plainly had.
DRAIN_TOLERANCE = 1.25
# Longest drain worth reporting. Past this the link did not recover from a
# burst, it is simply in a different state, and calling that a drain time
# would flatter it.
DRAIN_MAX_S = 30.0
def drain_after_load(samples, baseline_ms: float) -> dict:
"""How long latency took to fall back to baseline after load stopped.
Bufferbloat is reported everywhere as a depth — how much delay a busy
link adds. Depth alone cannot tell apart two links a user experiences
very differently: one whose queue fills and empties the instant traffic
stops, and one that stays full for seconds afterwards. The second ruins
a call after the download has finished; the first does not.
Pure, and fed the sample stream it already has: `(t, rtt, loaded)`
tuples, where the third element is the load tag added in 0.1.11. No
extra traffic, and nothing to schedule — the user's own usage supplies
the burst.
Returns `{"ms": None}` when there is nothing to say, which is most of
the time: no burst in the window, or the link never came back inside
`DRAIN_MAX_S`, or the baseline is unknown.
"""
out = {"ms": None, "settled": None}
if not samples or not baseline_ms or baseline_ms <= 0:
return out
# The most recent load -> idle transition, which is the only one whose
# recovery is still visible in this window.
last_loaded = None
for i, sm in enumerate(samples):
if len(sm) > 2 and sm[2]:
last_loaded = i
if last_loaded is None or last_loaded == len(samples) - 1:
return out # no burst, or still under load
ended_t = samples[last_loaded][0]
target = baseline_ms * DRAIN_TOLERANCE
for sm in samples[last_loaded + 1:]:
if sm[1] is None:
continue # a lost probe says nothing either way
if sm[1] <= target:
span = sm[0] - ended_t
if span > DRAIN_MAX_S:
return out
out["ms"] = round(max(0.0, span) * 1000.0, 0)
out["settled"] = True
return out
# Still above the baseline at the end of the window: report the floor it
# has already exceeded rather than a number implying it recovered.
span = samples[-1][0] - ended_t
if 0 < span <= DRAIN_MAX_S:
out["ms"] = round(span * 1000.0, 0)
out["settled"] = False
return out
# Queueing delay, in milliseconds, that separates a link carrying traffic
# comfortably from one holding packets up. Deliberately the same shape as
# the bufferbloat grades and the Latency tab's copy, and deliberately about
# the ABSOLUTE delay rather than its share of the round trip: a socket to
# another continent is mostly distance, and a proportion would call that
# congested.
PRESSURE_BUSY_MS = 10.0
PRESSURE_CONGESTED_MS = 30.0
def pressure(socket_queue_ms=None, loaded_ms=None, idle_ms=None) -> dict:
"""What the connection is doing RIGHT NOW, as opposed to lately.
The index cannot answer this and is not meant to. It is a weakest-link
score over three components, one of which — Speed — moves at
content-check cadence, so when it is the weakest the index barely
responds to anything else. Measured live: a saturating test drove
Responsiveness down 14.6 points while the index moved from 75.0 to
75.0, because Speed sat permanently lowest at 73.1. Both numbers were
correct; neither answered "is it bad right now".
So this is a separate, fast channel rather than a change to the index.
It reports queueing delay, which is the thing a user actually feels
during a burst, and it prefers the figure taken from their own TCP
connections (`sockets.queue_p50`, the kernel's own timing) over our
probes' loaded-minus-idle difference, because real traffic to real
destinations beats an inference from two sample populations.
Returns `state: None` when neither source can say, which is honest and
common on an idle machine with nothing to measure.
"""
src, q = None, None
if socket_queue_ms is not None and socket_queue_ms >= 0:
src, q = "sockets", float(socket_queue_ms)
elif (loaded_ms is not None and idle_ms is not None
and loaded_ms >= idle_ms):
# Only when the difference points the right way; queueing cannot be
# negative, and a negative difference means the split is unreliable
# rather than that load helped.
src, q = "probes", float(loaded_ms - idle_ms)
if q is None:
return {"state": None, "queue_ms": None, "source": None}
if q >= PRESSURE_CONGESTED_MS:
state = "congested"
elif q >= PRESSURE_BUSY_MS:
state = "busy"
else:
state = "clear"
return {"state": state, "queue_ms": round(q, 1), "source": src}
@@ -0,0 +1,561 @@
"""Speed measurement, two kinds.
Content speed: a short ranged download and a small upload, on a schedule, small enough to
be honest about the connection without being a burden on it. This is what
feeds the Speed score, following Orb's split — score the everyday number,
keep the fireworks manual.
Peak speed: saturates the line, only ever on demand. Prefers the official
Ookla CLI when installed (server choice, shareable result), falls back to
Cloudflare's endpoints via curl, then fast.com via the same API Omarchy's
built-in speed test uses. Both fallbacks need nothing installed beyond curl.
Loaded latency is sampled during the peak download by the daemon's existing
probes, not here — the test just records the window it ran in.
"""
import ipaddress
import json
import shutil
import socket
import subprocess
import threading
import time
from typing import Optional
from urllib.parse import urlparse
CLOUDFLARE_DOWN = "https://speed.cloudflare.com/__down?bytes={n}"
CLOUDFLARE_UP = "https://speed.cloudflare.com/__up"
# The token fast.com's own web client uses; Omarchy's built-in speed test
# ships the same one.
FAST_API = ("https://api.fast.com/netflix/speedtest/v2"
"?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=3")
def vet_target(url: str):
"""(url, --resolve argument) for a target we will fetch, else None.
Only for URLs WE DID NOT CHOOSE. fast.com nominates its own download
hosts, so that JSON decides what this daemon connects to, and it has
to be treated as hostile input rather than as a list of Netflix
servers. Three things must hold:
1. the scheme is https, so a nominated target cannot downgrade the
transfer to plaintext or hand curl a `file://` path;
2. EVERY address the host resolves to is public, so a speed test can
never be aimed at a router's admin page, a service on loopback,
or a link-local metadata address;
3. the address that passed (2) is the one curl actually connects to.
The third is the point most of this class gets wrong: resolving here
and letting curl resolve again is a check-then-use race, and a DNS
answer that returns a public address to us and a private one to curl
wins it. Pinning the vetted addresses with --resolve closes that
window, the same way every other read in this daemon is enforced on
the thing actually used rather than on a name looked up earlier.
"""
try:
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme != "https" or not parsed.hostname:
return None
port = parsed.port or 443
try:
infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
except (OSError, ValueError, UnicodeError):
return None
addrs = []
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
return None
if ip.version == 6 and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
# Spelled out rather than leaning on is_global alone, whose range
# table has been corrected across Python versions we may run on.
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
return None
addrs.append(str(ip))
if not addrs:
return None
return url, "%s:%d:%s" % (parsed.hostname, port, ",".join(addrs))
def _curl(args, timeout) -> Optional[subprocess.CompletedProcess]:
if not shutil.which("curl"):
return None
try:
# --proto =https refuses anything but TLS even if a target or a
# server tries something else; we never pass -L, so there is no
# redirect for it to follow either.
return subprocess.run(["curl", "-fsS", "--proto", "=https",
"--max-time", str(int(timeout))] + args,
capture_output=True, text=True, timeout=timeout + 5,
check=False)
except (subprocess.TimeoutExpired, OSError):
return None
# A transfer shorter than this carried too few bytes for its own duration to
# be worth dividing by: the timing error, not the line, would set the answer.
MIN_TIMED_WINDOW_S = 0.05
def _rate_over_payload(size: float, t_total: float, t_payload_start: float):
"""Mbps over the part of the request that actually carried bytes.
curl's own `speed_download` divides the bytes by the WHOLE request —
DNS, the TCP connect, the TLS handshake and the wait for the first byte
included. None of that carried payload, and none of it shrinks when the
line gets faster, so it is a fixed tax on a measurement whose useful part
keeps getting shorter: a 3 MB stream is ~240 ms of payload on a 400 Mbps
line and ~107 ms on a gigabit one, against the same ~70-90 ms of setup.
That is not noise. It is a bias that grows with the quantity being
measured, which made the check read a 900 Mbps line as roughly 220 and
put a ceiling near 480 on a scale whose top two anchors are 500 and 750.
Dividing by a window instead of by the total is only honest while the
window is long enough to divide by, so a sample too short to time is
withheld rather than published — the same rule the rest of the daemon
uses for a figure it cannot stand behind.
"""
window = t_total - t_payload_start
if window < MIN_TIMED_WINDOW_S or size <= 0:
return None
return size * 8 / 1e6 / window
def _curl_timed_download(url: str, timeout: float, resolve: str = None):
"""(mbps, bytes), timed over the payload rather than the whole request."""
pin = ["--resolve", resolve] if resolve else []
r = _curl(pin + ["-o", "/dev/null",
"-w", "%{size_download} %{time_total} %{time_starttransfer}",
url],
timeout)
if not r or r.returncode != 0:
return None, 0
try:
size, t_total, t_start = (float(x) for x in r.stdout.split())
except ValueError:
return None, 0
return _rate_over_payload(size, t_total, t_start), int(size)
def _upload_argv(url: str, timeout: float):
"""The one upload invocation, shared by the single and parallel forms.
The body is piped in — pointing curl at /dev/zero directly would have it
read the file to its end, which /dev/zero does not have."""
return ["curl", "-fsS", "--proto", "=https", "--max-time", str(int(timeout)),
"-o", "/dev/null", "-X", "POST", "--data-binary", "@-",
"-H", "Content-Type: application/octet-stream",
# Not time_starttransfer: on a POST that is the first byte of the
# RESPONSE, and against speed.cloudflare.com it arrives right after
# the handshake (the 100-continue), not after the body. The TLS
# handshake completing is when this request starts putting bytes
# on the wire.
"-w", "%{size_upload} %{time_total} %{time_appconnect}", url]
def _parallel_upload(url: str, per_stream: int, streams: int, timeout: float):
"""Sum of concurrent upload stream rates.
The download learned in 0.1.x that one TCP stream cannot fill a fast line —
a single-stream check read this 450 Mbps connection as 54 — and grew
`_parallel_download` for it. The upload never did, in either the hourly
check or the peak, so both were reading one stream's ceiling and calling
it the line. Measured here: the same 2 MB carried by four streams instead
of one read 36% higher, and 4 MB over four streams read more than twice
what the shipping 2 MB over one did.
Every stream is handed the same immutable buffer, so the memory cost is
one stream's worth of zeros rather than N.
"""
if not shutil.which("curl"):
return None, 0
body = b"\0" * per_stream
procs = []
for _ in range(streams):
try:
spawned = time.monotonic()
procs.append((subprocess.Popen(
_upload_argv(url, timeout), stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL), spawned))
except OSError:
pass
# A pipe holds far less than a stream's body, so writing them in turn
# would serialise the very thing being parallelised: each child gets a
# thread that feeds it and collects its result.
outs = [None] * len(procs)
def feed(i, proc):
try:
outs[i] = proc.communicate(input=body, timeout=timeout + 10)[0]
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
workers = [threading.Thread(target=feed, args=(i, proc), daemon=True)
for i, (proc, _) in enumerate(procs)]
for w in workers:
w.start()
for w in workers:
w.join(timeout + 15)
windows, total_bytes = [], 0
for i, (proc, spawned) in enumerate(procs):
if proc.returncode != 0 or not outs[i]:
continue
try:
size, t_total, t_app = (float(x) for x in outs[i].decode().split())
except (ValueError, UnicodeDecodeError):
continue
total_bytes += int(size)
windows.append((size, spawned + t_app, spawned + t_total))
return _aggregate_rate(windows), total_bytes
def _curl_timed_upload(url: str, n_bytes: int, timeout: float):
"""One upload stream. Kept for the peak's estimate pass, which only needs
a rough rate to size the real one."""
if not shutil.which("curl"):
return None, 0
cmd = ["curl", "-fsS", "--proto", "=https", "--max-time", str(int(timeout)),
"-o", "/dev/null", "-X", "POST", "--data-binary", "@-",
"-H", "Content-Type: application/octet-stream",
# Not time_starttransfer: on a POST that lands part way through
# the body, not after it. The TLS handshake completing is when
# this request starts putting bytes on the wire.
"-w", "%{size_upload} %{time_total} %{time_appconnect}", url]
try:
r = subprocess.run(cmd, input=b"\0" * n_bytes, capture_output=True,
timeout=timeout + 5, check=False)
except (subprocess.TimeoutExpired, OSError):
return None, 0
if r.returncode != 0:
return None, 0
try:
size, t_total, t_app = (float(x) for x in r.stdout.decode().split())
except (ValueError, UnicodeDecodeError):
return None, 0
return _rate_over_payload(size, t_total, t_app), int(size)
def _aggregate_rate(windows):
"""Mbps carried by a set of parallel streams.
NOT the sum of their individual rates. Streams do not start or finish
together — TLS handshakes complete tens to hundreds of milliseconds apart
— so a stream that outlives the others has the line to itself and measures
all of it. Adding that to what its siblings measured while sharing counts
the same link two, three, four times. Observed on this ~450 Mbps line:
summing gave 647 / 629 / 538 Mbps for transfers that actually carried
323 / 276 / 269.
The honest figure is what crossed the wire divided by the time the wire
spent carrying it: total bytes over the union of the streams' payload
windows. The union rather than first-start-to-last-finish, so a gap
between streams is not billed as throughput.
`windows` is (bytes, absolute start, absolute end) per stream.
"""
windows = [w for w in windows if w[0] > 0 and w[2] > w[1]]
if not windows:
return None
total_bytes = sum(w[0] for w in windows)
spans = sorted((w[1], w[2]) for w in windows)
union, cur_s, cur_e = 0.0, spans[0][0], spans[0][1]
for s, e in spans[1:]:
if s > cur_e:
union += cur_e - cur_s
cur_s, cur_e = s, e
else:
cur_e = max(cur_e, e)
union += cur_e - cur_s
if union < MIN_TIMED_WINDOW_S:
return None
return total_bytes * 8 / 1e6 / union
def _parallel_download(url: str, streams: int, timeout: float):
"""Sum of concurrent stream rates.
One TCP stream at ~10 ms of latency tops out far below a fast line's
capacity — a single-stream check read this 450 Mbps connection as 54.
Real page loads and video players open several connections, so several
streams is the honest simulation, and their sum is the number.
"""
if not shutil.which("curl"):
return None, 0
procs = []
for _ in range(streams):
try:
# curl times everything from its own start, and the children are
# spawned a few milliseconds apart, so their clocks have to be
# put on a common origin before their windows can be compared.
spawned = time.monotonic()
procs.append((subprocess.Popen(
["curl", "-fsS", "--proto", "=https",
"--max-time", str(int(timeout)), "-o", "/dev/null",
"-w", "%{size_download} %{time_total} %{time_starttransfer}",
url],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True),
spawned))
except OSError:
pass
windows, total_bytes = [], 0
for p, spawned in procs:
try:
out, _ = p.communicate(timeout=timeout + 10)
except subprocess.TimeoutExpired:
p.kill()
p.wait()
continue
if p.returncode != 0:
continue
try:
size, t_total, t_start = (float(x) for x in out.split())
except ValueError:
continue
total_bytes += int(size)
windows.append((size, spawned + t_start, spawned + t_total))
return _aggregate_rate(windows), total_bytes
# Each stream aims for about this much time actually carrying bytes: long
# enough that TCP's ramp-up is a small share of what is timed, short enough
# that the check stays something nobody notices.
CONTENT_TARGET_S = 0.5
# A stream never goes below this, so a hint that came in low cannot shrink
# the next transfer into a degenerate one.
CONTENT_STREAM_FLOOR = 500_000
# And never above this, which is what bounds the hourly data budget. Only a
# fast line reaches either cap; everything slower asks for less and gets it.
CONTENT_DOWN_STREAM_CAP = 3_000_000
CONTENT_UP_STREAM_CAP = 2_000_000
def content_stream_bytes(hint_mbps, streams: int, cap: int) -> int:
"""Bytes for one stream: about CONTENT_TARGET_S of payload at the rate
this line last showed, bounded both ways.
A fixed size cannot serve both ends of the range it has to. Twelve MB is
a quarter of a second on a fast line and nine seconds of a saturated link
on a 10 Mbps one — the users least able to spare it were paying the most
for it, hourly. Sizing by time inverts that: the cap is reached only by
lines that can afford it, and a slow line asks for a fraction.
With no hint — the first check on a network — the cap is what it sends,
because there is nothing yet to size against and one honest measurement
is what produces the hint for every check after it.
"""
if not hint_mbps or hint_mbps <= 0:
return cap
per_stream_mbps = float(hint_mbps) / max(1, streams)
want = int(per_stream_mbps / 8 * CONTENT_TARGET_S * 1e6)
return max(CONTENT_STREAM_FLOOR, min(cap, want))
def content_test(down_hint_mbps=None, up_hint_mbps=None,
streams: int = 4) -> dict:
"""The scheduled check.
Both directions are carried by `streams` parallel connections, each sized
for a target duration rather than by dividing a fixed budget. Dividing a
budget was how the download bug worked from one side and the upload's from
the other: more streams meant shorter streams, and a stream too short to
time is withheld.
Costs up to ~20 MB on a line fast enough to reach both caps, and a
fraction of that below — about 2 MB on a 25 Mbps line, where the old fixed
16 MB took nine seconds of the link every hour.
"""
started = time.time()
per_stream = content_stream_bytes(down_hint_mbps, streams,
CONTENT_DOWN_STREAM_CAP)
up_per_stream = content_stream_bytes(up_hint_mbps, streams,
CONTENT_UP_STREAM_CAP)
down_mbps, down_n = _parallel_download(
CLOUDFLARE_DOWN.format(n=per_stream), streams, timeout=30)
if down_mbps is None and per_stream < CONTENT_DOWN_STREAM_CAP:
# Sized from history, and the line turned out to be faster than that
# history says — so fast that the streams finished inside the window
# too short to time, and were withheld. Nothing is stored for a
# withheld check, so the hint would never learn better and every
# check after this one would ask for the same too-short transfer and
# report nothing, forever. One pass at the cap re-anchors it.
retry_mbps, retry_n = _parallel_download(
CLOUDFLARE_DOWN.format(n=CONTENT_DOWN_STREAM_CAP), streams,
timeout=30)
down_mbps, down_n = retry_mbps, down_n + retry_n
up_mbps, up_n = _parallel_upload(CLOUDFLARE_UP, up_per_stream, streams,
timeout=30)
if up_mbps is None and up_per_stream < CONTENT_UP_STREAM_CAP:
retry_mbps, retry_n = _parallel_upload(
CLOUDFLARE_UP, CONTENT_UP_STREAM_CAP, streams, timeout=30)
up_mbps, up_n = retry_mbps, up_n + retry_n
return {
"kind": "content",
"engine": "cloudflare",
"ok": down_mbps is not None,
"down_mbps": round(down_mbps, 1) if down_mbps else None,
"up_mbps": round(up_mbps, 1) if up_mbps else None,
"bytes": down_n + up_n,
"started": started,
"ended": time.time(),
}
def _peak_ookla() -> Optional[dict]:
"""The official Speedtest CLI, when the user has installed it."""
if not shutil.which("speedtest"):
return None
try:
r = subprocess.run(
["speedtest", "--format=json", "--accept-license", "--accept-gdpr"],
capture_output=True, text=True, timeout=120, check=False)
except (subprocess.TimeoutExpired, OSError):
return None
if r.returncode != 0:
return None
try:
j = json.loads(r.stdout)
return {
"engine": "ookla",
"ok": True,
"down_mbps": round(j["download"]["bandwidth"] * 8 / 1e6, 1),
"up_mbps": round(j["upload"]["bandwidth"] * 8 / 1e6, 1),
"ping_idle": round(j["ping"]["latency"], 1),
"jitter": round(j["ping"].get("jitter", 0), 1),
"bytes": j["download"].get("bytes", 0) + j["upload"].get("bytes", 0),
"server": f'{j["server"].get("name", "")} · {j["server"].get("location", "")}',
"url": j.get("result", {}).get("url", ""),
}
except (ValueError, KeyError, TypeError, AttributeError):
# Someone else's JSON: a missing key, a string where a number was
# expected, a list where an object was. Any of those is a failed
# engine, not a dead worker thread.
return None
PEAK_TARGET_S = 10 # aim each sustained pass at about this long
PEAK_STREAMS = 4
# __down 403s any single request of 100 MB or more; each parallel stream
# stays under that and the streams together still carry a fast line.
CLOUDFLARE_DOWN_MAX = 99_999_999
PEAK_DOWN_FLOOR = 10_000_000
PEAK_UP_FLOOR = 5_000_000
PEAK_UP_CAP = 100_000_000 # also bounds the in-memory upload body
def _sized_pass(mbps: float, floor: int, cap: int) -> int:
"""Bytes that should take about PEAK_TARGET_S at the measured rate."""
return max(floor, min(cap, int(mbps / 8 * PEAK_TARGET_S * 1e6)))
def _pass_seconds(mbps: float, n_bytes: int) -> float:
return n_bytes * 8 / (mbps * 1e6)
def _peak_cloudflare() -> Optional[dict]:
"""An estimate pass sizes a sustained pass.
Fixed sizes made the whole test finish inside TCP ramp-up on a fast
line (2-3 s end to end), which both under-reads the line and leaves
the loaded-latency window with a handful of probe samples. The
estimate pass measures the rate; the sustained pass is sized to hold
that rate for ~PEAK_TARGET_S, split over parallel streams because a
single stream can neither exceed the per-request byte cap nor fill a
fast line by itself. A slow line's estimate pass already runs that
long and doubles as the sustained pass.
"""
total = 0
best_down, size = _curl_timed_download(CLOUDFLARE_DOWN.format(n=25_000_000),
timeout=40)
total += size
if best_down and _pass_seconds(best_down, size) < PEAK_TARGET_S * 0.6:
n = _sized_pass(best_down / PEAK_STREAMS, PEAK_DOWN_FLOOR,
CLOUDFLARE_DOWN_MAX)
mbps, size = _parallel_download(CLOUDFLARE_DOWN.format(n=n),
PEAK_STREAMS, timeout=40)
total += size
if mbps:
best_down = max(best_down, mbps)
best_up = 0.0
up_est, size = _curl_timed_upload(CLOUDFLARE_UP, 10_000_000, timeout=40)
total += size
if up_est:
best_up = up_est
if _pass_seconds(up_est, size) < PEAK_TARGET_S * 0.6:
# Per stream, as the download pass already sizes itself: the same
# total goes up, split four ways, so this costs no more data than
# the single stream it replaces and stops reading one stream's
# ceiling as the line.
n = _sized_pass(up_est / PEAK_STREAMS, PEAK_UP_FLOOR // PEAK_STREAMS,
PEAK_UP_CAP // PEAK_STREAMS)
mbps, size = _parallel_upload(CLOUDFLARE_UP, n, PEAK_STREAMS,
timeout=40)
total += size
if mbps:
best_up = max(best_up, mbps)
if not best_down:
return None
return {
"engine": "cloudflare",
"ok": True,
"down_mbps": round(best_down, 1),
"up_mbps": round(best_up, 1) if best_up else None,
"bytes": total,
"server": "speed.cloudflare.com",
}
def _peak_fast() -> Optional[dict]:
"""Download-only, via the Netflix OCA endpoints fast.com hands out.
The API picks the hosts, so each one is vetted before it is fetched
(see vet_target) and a target that does not pass is skipped rather
than failing the test — a bad entry in someone else's JSON should
cost us one candidate, not the measurement.
"""
r = _curl([FAST_API], timeout=15)
if not r or r.returncode != 0:
return None
try:
targets = [t["url"] for t in json.loads(r.stdout).get("targets", []) if t.get("url")]
except (ValueError, KeyError, TypeError, AttributeError):
return None
vetted = [v for v in (vet_target(u) for u in targets if isinstance(u, str)) if v]
best = 0.0
total = 0
for url, resolve in vetted[:3]:
mbps, size = _curl_timed_download(url, timeout=30, resolve=resolve)
total += size
if mbps:
best = max(best, mbps)
if not best:
return None
return {"engine": "fast.com", "ok": True, "down_mbps": round(best, 1),
"up_mbps": None, "bytes": total, "server": "Netflix OCA"}
def peak_test(engine: str = "Auto") -> dict:
"""On-demand, engine per the user's setting."""
started = time.time()
order = {
"Auto": (_peak_ookla, _peak_cloudflare, _peak_fast),
"Ookla": (_peak_ookla,),
"Cloudflare": (_peak_cloudflare,),
"fast.com": (_peak_fast,),
}.get(engine, (_peak_ookla, _peak_cloudflare, _peak_fast))
for fn in order:
result = fn()
if result:
result.update({"kind": "peak", "started": started, "ended": time.time()})
return result
return {"kind": "peak", "engine": engine, "ok": False,
"started": started, "ended": time.time()}
@@ -0,0 +1,149 @@
"""Reading and writing the JSON state files, safely.
live.json is rewritten twice a second and is all the bar widget ever looks
at; recent.json is a pre-downsampled 30-minute window so the panel's default
graphs paint without a query; apps.json is per-application traffic. All are
written to a temp file and renamed, so a reader never sees a half-written
file. The QML side does not open any of them itself (0.1.9): `nexthop
stream` reads them through `read_text_bounded` — no symlink following, a
regular file or nothing, a size cap on the read itself — and hands the shell
one re-serialised line per record.
"""
import json
import os
import stat
import tempfile
import time
from pathlib import Path
from .paths import APPS, LIVE, RECENT
def write_atomic(path: Path, payload: dict, durable: bool = False):
"""Write a JSON file so a reader sees the old one or the new one.
The temp file plus rename is what gives readers that guarantee, and it
costs nothing. The fsync is a different promise — that the bytes
survive a power cut — and none of the snapshots written here needs
it: each is replaced within seconds of the daemon starting. On btrfs
that fsync was 25× the payload at the block layer (62.5 KiB per 2.5 KB
write, measured), twice a second. `durable` keeps it for a caller
that genuinely wants it.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
try:
with os.fdopen(fd, "w") as f:
json.dump(payload, f, separators=(",", ":"))
f.flush()
if durable:
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def read_text_bounded(path: Path, max_bytes: int):
"""Read a state file, enforcing every property on the fd actually read.
`O_NOFOLLOW` refuses a symlinked path outright, `O_NONBLOCK` means a
FIFO left at the path returns instead of stalling the caller, `fstat`
on the descriptor proves it is a regular file, and the cap bounds the
read itself rather than trusting a size sampled beforehand. Returns
(text, stamp) or None; `stamp` is (mtime_ns, size), enough for a
caller to skip re-reading an unchanged file.
This is the only way state reaches a reader — the QML side consumes it
through `nexthop stream` rather than opening these paths itself, so an
oversized or non-regular file can never allocate or block inside the
long-lived shell process.
"""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
except OSError:
return None
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
return None
chunks, total = [], 0
while total <= max_bytes:
try:
chunk = os.read(fd, min(65536, max_bytes + 1 - total))
except BlockingIOError:
break
except OSError:
return None
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > max_bytes:
return None
stamp = (st.st_mtime_ns, st.st_size)
finally:
os.close(fd)
try:
return b"".join(chunks).decode("utf-8"), stamp
except UnicodeDecodeError:
return None
def read_json(path: Path, default=None, max_bytes: int = 4 * 1024 * 1024):
"""Bounded read of a state file, parsed. The bound lives on the read,
not on a prior stat, for the same reason as the daemon's config read."""
got = read_text_bounded(path, max_bytes)
if got is None:
return default
try:
return json.loads(got[0])
except ValueError:
return default
def retire_legacy_snapshots(old_dir: Path, new_dir: Path, now: float = None):
"""The snapshots moved to the runtime dir in 0.2.22; tidy the old place.
`nexthop stream` is a long-lived process that resolved its paths when it
started, so a reader from before the move keeps watching the state dir
for as long as it lives — through the daemon handover, until the shell
reloads the QML. Deleting live.json there would leave that reader
holding the last number it saw, as if it were current. It is rewritten
once instead, as a tombstone: state "no-daemon", no index, and no pid,
so the old bar shows "no data" and the old version watch finds nothing
to retire. recent.json and apps.json are simply removed.
"""
old_dir, new_dir = Path(old_dir), Path(new_dir)
if old_dir == new_dir:
return
now = time.time() if now is None else now
for name in (RECENT, APPS):
try:
(old_dir / name).unlink()
except OSError:
pass
live = old_dir / LIVE
got = read_text_bounded(live, 256 * 1024)
if got is None:
return
try:
payload = json.loads(got[0])
except ValueError:
payload = None
if not isinstance(payload, dict):
try:
live.unlink()
except OSError:
pass
return
for key in ("pid", "pid_start", "daemon_version"):
payload.pop(key, None)
payload.update({"t": round(now, 3), "state": "no-daemon", "index": None,
"band": None, "down_since": None})
write_atomic(live, payload, durable=True)
@@ -0,0 +1,402 @@
"""Persistence: per-minute rows, hourly rollups, tests and events.
sqlite from the standard library, in WAL mode so the CLI can read a window
of history while the daemon is mid-write. Raw half-second samples never
reach the disk — they are folded into a minute row and discarded, which is
what keeps a month of continuous monitoring under about 12 MB.
"""
import functools
import sqlite3
import threading
import time
from pathlib import Path
from .probes import nearest_rank
SAMPLE_COLUMNS = [
"local_p50", "local_p95", "local_jitter", "local_loss",
"wan_p50", "wan_p95", "wan_jitter", "wan_loss",
# 0.2.20: the two order statistics the scored fold and its critics
# actually turn on. Lag leans on p75; Orb headlines a high-water max;
# LibreQoS takes a phase percentile. Comparing those against our own
# history was only possible as an upper bound because neither was ever
# written down — p50 and p95 alone cannot reconstruct either. Recorded
# now, scored never: the decision needs real days behind it, the same
# rule loaded latency was held to in 0.1.11.
"local_p75", "local_max", "wan_p75", "wan_max",
"lag", "rx_bps", "tx_bps", "signal_dbm",
"resp", "rel", "spd", "idx",
# Latency split by what the link was doing at the time. The gap between
# them is bufferbloat, and it only accumulates into something worth
# scoring if it is recorded minute by minute first.
"lag_idle", "lag_loaded",
# 0.2.0: what ICMP alone would have scored, beside the instrument-
# scored lag — the basis switch stays auditable per minute.
"lag_icmp",
]
# Minute-only, and deliberately not in SAMPLE_COLUMNS: `rollup_hours` averages
# everything in that list, and a mean of drains destroys the one thing the
# drain is being stored for. Its value is quantised by probe cadence, so what
# has to survive is the DISTRIBUTION — sixty of them averaged is a number with
# none of that in it.
#
# 0.2.37. Until now the drain was published to live.json and stored nowhere,
# so a figure on screen could never be checked afterwards; the distribution
# that showed it was quantised had to come from the other implementation
# because this one had no history to look at.
#
# Three numbers rather than one. `drain_settled` says whether the link
# recovered or the window merely ended, so a censored floor is not read as a
# measurement. `drain_min_ms` is the tightest bound across the seated
# instruments beside the loosest, which is what is published today — carrying
# both is what lets the choice between them be settled from history instead of
# argued. `drain_src` names the instrument the published value came from,
# because each instrument's value is floored at its own cadence and knowing
# which one won is the difference between an auditable figure and a guess.
MINUTE_ONLY_REAL = ["drain_ms", "drain_min_ms", "drain_settled"]
MINUTE_ONLY_TEXT = ["drain_src"]
_COLS_SQL = ", ".join(f"{c} REAL" for c in SAMPLE_COLUMNS)
_MINUTE_EXTRA_SQL = ", ".join(
[f"{c} REAL" for c in MINUTE_ONLY_REAL] + [f"{c} TEXT" for c in MINUTE_ONLY_TEXT])
SCHEMA = f"""
CREATE TABLE IF NOT EXISTS minute (
ts INTEGER PRIMARY KEY, {_COLS_SQL}, {_MINUTE_EXTRA_SQL},
iface TEXT, network TEXT, probes TEXT
);
CREATE TABLE IF NOT EXISTS hour (
ts INTEGER PRIMARY KEY, {_COLS_SQL}, iface TEXT, network TEXT
);
CREATE TABLE IF NOT EXISTS tests (
ts INTEGER PRIMARY KEY, kind TEXT, engine TEXT,
down_mbps REAL, up_mbps REAL, ping_idle REAL, ping_loaded REAL,
jitter REAL, bytes INTEGER, server TEXT, ok INTEGER, detail TEXT,
network TEXT
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, ended_ts INTEGER, kind TEXT, severity TEXT,
leg TEXT, detail TEXT
);
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
CREATE INDEX IF NOT EXISTS tests_kind_ts ON tests(kind, ts);
"""
def _locked(method):
"""Serialise access to the one connection — see Store."""
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
with self._lock:
return method(self, *args, **kwargs)
return wrapper
class Store:
"""One connection, one lock.
Three threads reach this object: the daemon loop (minute rows, events,
a read for Reliability on every tick), the content-test worker and the
peak-test worker (one row each when they finish). The connection is
opened with `check_same_thread=False`, which only tells the sqlite3
module to allow that — it does not make concurrent use of one
connection safe, and a forced overlap reproduces "bad parameter or
other API misuse" and a lost row. Every transaction here is a few
milliseconds, so a mutex is the whole fix; a writer thread with a
queue was considered and is more machinery than three callers need.
"""
def __init__(self, path: Path, read_only: bool = False):
self.path = Path(path)
self._lock = threading.RLock()
if read_only:
uri = f"file:{self.path}?mode=ro"
self.db = sqlite3.connect(uri, uri=True, timeout=5.0)
else:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.db = sqlite3.connect(self.path, timeout=5.0,
check_same_thread=False)
self.db.executescript(SCHEMA)
self._migrate()
self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("PRAGMA synchronous=NORMAL")
self.db.commit()
self.db.row_factory = sqlite3.Row
def _migrate(self):
"""Additive migrations for databases created by older versions."""
for table, column in (("tests", "network"),
("minute", "lag_idle"), ("minute", "lag_loaded"),
("hour", "lag_idle"), ("hour", "lag_loaded"),
("minute", "lag_icmp"), ("hour", "lag_icmp"),
("minute", "probes"),
("minute", "local_p75"), ("hour", "local_p75"),
("minute", "local_max"), ("hour", "local_max"),
("minute", "wan_p75"), ("hour", "wan_p75"),
("minute", "wan_max"), ("hour", "wan_max"),
("minute", "drain_ms"),
("minute", "drain_min_ms"),
("minute", "drain_settled"),
("minute", "drain_src")):
try:
self.db.execute(
f"ALTER TABLE {table} ADD COLUMN {column} "
f"{'TEXT' if column in ('network', 'probes', 'drain_src') else 'REAL'}")
except sqlite3.OperationalError:
pass # column already there
@_locked
def close(self):
try:
self.db.close()
except sqlite3.Error:
pass
# ---------------------------------------------------------------- writes
@_locked
def put_minute(self, ts: int, values: dict, iface: str = "",
network: str = "", probes: str = ""):
extra = MINUTE_ONLY_REAL + MINUTE_ONLY_TEXT
cols = ["ts"] + SAMPLE_COLUMNS + extra + ["iface", "network", "probes"]
row = ([int(ts)]
+ [values.get(c) for c in SAMPLE_COLUMNS]
+ [values.get(c) for c in extra]
+ [iface, network, probes])
placeholders = ", ".join("?" * len(cols))
self.db.execute(
f"INSERT OR REPLACE INTO minute ({', '.join(cols)}) VALUES ({placeholders})",
row,
)
self.db.commit()
@_locked
def put_test(self, ts: int, kind: str, engine: str, **kw):
self.db.execute(
"""INSERT OR REPLACE INTO tests
(ts, kind, engine, down_mbps, up_mbps, ping_idle, ping_loaded,
jitter, bytes, server, ok, detail, network)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(int(ts), kind, engine, kw.get("down_mbps"), kw.get("up_mbps"),
kw.get("ping_idle"), kw.get("ping_loaded"), kw.get("jitter"),
kw.get("bytes"), kw.get("server"), 1 if kw.get("ok", True) else 0,
kw.get("detail", ""), kw.get("network", "")),
)
self.db.commit()
@_locked
def open_event(self, ts: int, kind: str, severity: str, leg: str, detail: str) -> int:
cur = self.db.execute(
"INSERT INTO events (ts, kind, severity, leg, detail) VALUES (?,?,?,?,?)",
(int(ts), kind, severity, leg, detail),
)
self.db.commit()
return cur.lastrowid
@_locked
def close_event(self, event_id: int, ended_ts: int, detail: str = None):
if detail is None:
self.db.execute("UPDATE events SET ended_ts=? WHERE id=?",
(int(ended_ts), event_id))
else:
self.db.execute("UPDATE events SET ended_ts=?, detail=? WHERE id=?",
(int(ended_ts), detail, event_id))
self.db.commit()
# ------------------------------------------------------------- maintenance
@_locked
def rollup_hours(self, now: float = None):
"""Fold complete minutes into hour rows.
Averages the averages, which is fair because every minute row covers
the same span. Percentiles do not survive that — an hourly p95 built
from sixty per-minute p95s is a mean of p95s, and it is labelled as
such wherever it is displayed. The same caveat binds harder to the
new max columns: an hourly `local_max` is a mean of sixty maxima,
which is not the hour's worst sample and must never be shown as one.
Use the minute rows for anything that reasons about the tail.
An hour that spanned two networks is labelled with neither: MAX()
would pick whichever name sorts last and file the other network's
minutes under it. Blank is "mixed", which no consumer can mistake
for a network.
"""
now = now or time.time()
current_hour = int(now // 3600) * 3600
avg = ", ".join(f"AVG({c}) AS {c}" for c in SAMPLE_COLUMNS)
self.db.execute(
f"""INSERT OR REPLACE INTO hour
(ts, {', '.join(SAMPLE_COLUMNS)}, iface, network)
SELECT (ts / 3600) * 3600 AS bucket, {avg},
CASE WHEN COUNT(DISTINCT iface) > 1 THEN ''
ELSE MAX(iface) END,
CASE WHEN COUNT(DISTINCT network) > 1 THEN ''
ELSE MAX(network) END
FROM minute WHERE ts < ? GROUP BY bucket""",
(current_hour,),
)
self.db.commit()
@_locked
def prune(self, minute_days: int = 7, hour_days: int = 400, now: float = None):
now = now or time.time()
self.db.execute("DELETE FROM minute WHERE ts < ?",
(int(now - minute_days * 86400),))
self.db.execute("DELETE FROM hour WHERE ts < ?",
(int(now - hour_days * 86400),))
# Events were never pruned before 0.2.21 — about sixty rows a day,
# unbounded. They keep the hourly history's horizon.
self.db.execute("DELETE FROM events WHERE ts < ?",
(int(now - hour_days * 86400),))
self.db.commit()
@_locked
def close_orphans(self, now: float = None) -> int:
"""Close events a previous daemon left open. Returns how many.
Only the daemon that opened an event can close it, so one that
died mid-outage — or was retired by the version handover with a
rate-drop open — leaves `ended_ts` NULL for good. Two readers
treat NULL as "still happening": `outage_stats` would charge such
an outage against every Reliability window forever, and `events`
would list it as ongoing. When it actually ended is unknowable,
so it is closed at the shortest span the store accepts rather
than at a guessed later time: undercharging by the lost tail is
the safe direction, and inventing a duration is not.
Called once, after the lock is held — a second daemon that loses
the flock must not close the running one's events on its way out.
"""
cur = self.db.execute(
"UPDATE events SET ended_ts = ts + 1 WHERE ended_ts IS NULL")
self.db.commit()
return cur.rowcount
# ---------------------------------------------------------------- reads
@_locked
def series(self, seconds: float, now: float = None,
resolution: str = "auto") -> list:
"""History over a window, at whichever resolution suits it.
Auto: under six hours reads per-minute rows; anything longer reads
hourly ones, so a seven-day graph is 168 points rather than 10,080.
Callers that genuinely want the fine rows (the 24 h experience
ribbon) ask for "minute" explicitly.
"""
now = now or time.time()
if resolution in ("minute", "hour"):
table = resolution
else:
table = "minute" if seconds <= 6 * 3600 else "hour"
rows = self.db.execute(
f"SELECT * FROM {table} WHERE ts >= ? ORDER BY ts",
(int(now - seconds),),
).fetchall()
return [dict(r) for r in rows], table
@_locked
def tests(self, limit: int = 20, kind: str = None) -> list:
if kind:
rows = self.db.execute(
"SELECT * FROM tests WHERE kind=? ORDER BY ts DESC LIMIT ?",
(kind, limit)).fetchall()
else:
rows = self.db.execute(
"SELECT * FROM tests ORDER BY ts DESC LIMIT ?", (limit,)).fetchall()
return [dict(r) for r in rows]
@_locked
def events(self, seconds: float = 7 * 86400, limit: int = 100,
now: float = None) -> list:
"""Events overlapping the window, newest first.
Filtering on start time alone dropped an outage that began before
the window and ended inside it — the one the user opens the list
to see. Same overlap rule `outage_stats` has always used.
"""
now = now or time.time()
start = int(now - seconds)
rows = self.db.execute(
"""SELECT * FROM events
WHERE ts >= ? OR ended_ts IS NULL OR ended_ts >= ?
ORDER BY ts DESC LIMIT ?""",
(start, start, limit)).fetchall()
return [dict(r) for r in rows]
@_locked
def baseline_speed(self, days: int = 30, network: str = "",
min_samples: int = 5, now: float = None,
fallback: bool = True):
"""This connection's own normal: the p90 of recent content downloads.
p90 rather than max so one lucky quiet-hour run does not set a bar
the line can never reach again. Scoped to the current network when
it has enough samples — the office's normal is not the home's —
falling back to all networks, and to None until there is enough
history to mean anything.
"""
now = now or time.time()
since = int(now - days * 86400)
def p90(rows):
vals = sorted(r["down_mbps"] for r in rows
if r["down_mbps"] is not None)
if len(vals) < min_samples:
return None
return nearest_rank(vals, 0.9)
if network:
rows = self.db.execute(
"""SELECT down_mbps FROM tests
WHERE kind='content' AND ok=1 AND ts >= ? AND network = ?""",
(since, network)).fetchall()
result = p90(rows)
if result is not None:
return result
# The caller decides whether a cross-network baseline is meaningful.
# For the degradation penalty it is not: "is it normal here" cannot
# be answered with another network's normal.
if not fallback:
return None
rows = self.db.execute(
"""SELECT down_mbps FROM tests
WHERE kind='content' AND ok=1 AND ts >= ?""",
(since,)).fetchall()
return p90(rows)
@_locked
def outage_stats(self, seconds: float, now: float = None):
"""(fraction fully down, count of disruptions, fraction disrupted).
Disruptions carry their duration as well as their count because
reliability charges both kinds of interruption in the same currency —
time. Counting alone made three brief blips outweigh an hour offline.
"""
now = now or time.time()
start = now - seconds
rows = self.db.execute(
"""SELECT ts, ended_ts, kind FROM events
WHERE kind IN ('outage', 'disruption') AND (ended_ts IS NULL OR ended_ts >= ?)""",
(int(start),)).fetchall()
down = 0.0
disrupted = 0.0
disruptions = 0
for r in rows:
begin = max(r["ts"], start)
end = r["ended_ts"] if r["ended_ts"] else now
end = min(end, now)
if end <= begin:
continue
if r["kind"] == "outage":
down += end - begin
else:
disruptions += 1
disrupted += end - begin
span = seconds if seconds else 0.0
return ((down / span if span else 0.0), disruptions,
(disrupted / span if span else 0.0))
@@ -0,0 +1,246 @@
"""Is a newer version published? Notify, never install.
Omarchy checks itself for updates (`omarchy-update-available` looks at its own
checkout and its package) but nothing checks plugins, so a user can sit on an
old Nexthop indefinitely without ever being told. This closes that gap the
smallest way it can be closed.
**This module never updates anything.** It answers one question — is the
installed checkout behind its origin — and the answer becomes a quiet glyph in
the panel naming the command the user can run. Updating stays where Omarchy
put it: `omarchy plugin update`, which shows the diff and asks. A plugin that
fetched and ran new code would be self-modifying code inside a system that
deliberately gates updates behind human review, and it would reopen a security
review that took four rounds to clear.
How it stays cheap and read-only:
* `git ls-remote` asks the remote for its HEAD without writing a single byte
into the user's checkout — no fetch, no new objects, no refs touched. It
takes well under a second and needs no credentials.
* Whether we are *behind* rather than merely *different* is then decided from
objects we already have, so a developer checkout that is ahead of origin is
never nagged.
* Egress is to the repository the user installed from and nowhere else. It
carries nothing about them or their network. It is still egress, so it is
disclosed and `updateCheck` turns it off.
The one piece of untrusted input here is the commit id the remote hands back,
and it goes on to be an argument to another `git` call — so it is validated
against the exact 40-hex shape before it reaches a subprocess, the same
doctrine `vet_target()` applies to URLs we did not choose.
"""
import os
import re
import shutil
import subprocess
import threading
from pathlib import Path
# A git object id and nothing else. This is the guard that matters: the value
# arrives from the network and is then passed as an argument to git.
RE_SHA = re.compile(r"^[0-9a-f]{40}$")
# A release is a rare event, and the check exists to catch the user who would
# otherwise never look. Daily is generous.
CHECK_INTERVAL_S = 24 * 3600
# Not at startup: the daemon restarts with the shell, and a check on every
# restart would be noise for no benefit. Nothing is lost by waiting.
FIRST_CHECK_DELAY_S = 300
# The remote may be slow, unreachable, or a captive portal that answers
# everything. None of those may stall the daemon.
GIT_TIMEOUT_S = 20
# `ls-remote` prints one short line per ref; this is far past HEAD alone.
MAX_LS_REMOTE_BYTES = 64 * 1024
def verdict(local: str, remote: str, have_remote: bool,
head_before_remote: bool, remote_before_head: bool) -> str:
"""Where the checkout stands relative to origin. Pure, so it is testable.
* `current` — the same commit.
* `behind` — origin is strictly ahead of us: an update.
* `ahead` — we are strictly ahead of origin: a dev checkout.
* `diverged` — neither contains the other.
* `unknown` — we could not tell, and say so rather than guessing.
Both ancestry directions are needed, and the reason is the likeliest
case of all: `omarchy plugin update` fetches before it shows its diff, so
a user who looked and said "not now" already *holds* origin's commit
while still being behind it. Deciding "behind" from "we have never seen
that object" alone would show that user nothing.
"""
if not local or not remote:
return "unknown"
if local == remote:
return "current"
if not have_remote:
# We do not hold origin's commit at all, so it is newer than anything
# we know about.
return "behind"
if head_before_remote:
return "behind"
if remote_before_head:
return "ahead"
return "diverged"
class UpdateWatch:
"""Asks origin, on a slow cadence, whether this checkout is behind.
The result lives in memory only. A daemon restart forgets it and waits
`FIRST_CHECK_DELAY_S` before asking again, which is why no fifth state
file was added for this.
"""
def __init__(self, repo: Path = None, enabled: bool = True, spawn=None):
# Derived from this file, not from the working directory: the daemon
# can be started from anywhere.
self.repo = Path(repo) if repo else Path(__file__).resolve().parent.parent
self.enabled = enabled
self.state = "unknown"
self.checked_ts = None
self._next = None
# How a check is run. Off the loop by default: `ls-remote` may sit
# at its 20 s timeout on exactly the flaky network where the
# outage watch matters, and the loop must not wait for it. Tests
# pass a synchronous spawn so the verdict lands within the tick.
self._spawn = spawn or self._in_thread
self._lock = threading.Lock()
self._inflight = False
self._pending = None # a verdict awaiting the next tick
@staticmethod
def _in_thread(fn):
threading.Thread(target=fn, name="update-check", daemon=True).start()
def _git(self, *args, capture: bool = True):
"""One git call. Fixed argv, no shell, bounded, always timed out."""
env = dict(os.environ)
# A credential prompt on a private or moved remote would otherwise
# block until the timeout every single time.
env["GIT_TERMINAL_PROMPT"] = "0"
env.pop("GIT_ASKPASS", None)
env.pop("SSH_ASKPASS", None)
# `git -C <dir>` does not require <dir> to BE a repository: git
# walks up until it finds one. A copy-install with no .git of its
# own, inside a home that is itself a checkout (dotfiles), would
# otherwise be compared against — and contact the remote of — the
# wrong repository every day. The ceiling stops the walk at our
# parent, so a missing .git answers "unknown", never someone else.
env["GIT_CEILING_DIRECTORIES"] = str(self.repo.parent)
try:
proc = subprocess.run(
["git", "-C", str(self.repo),
# No credential helper and no askpass program may run for
# this: a private or moved remote gets "unknown", not a
# keyring prompt once a day. GIT_TERMINAL_PROMPT above
# covers only git's own tty prompt.
"-c", "credential.helper=", "-c", "core.askPass=",
*args],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=GIT_TIMEOUT_S, env=env,
)
except (OSError, subprocess.TimeoutExpired):
return None, ""
out = ""
if capture and proc.stdout:
out = proc.stdout[:MAX_LS_REMOTE_BYTES].decode(
"utf-8", "replace").strip()
return proc.returncode, out
def _local_head(self):
code, out = self._git("rev-parse", "HEAD")
return out if code == 0 and RE_SHA.match(out) else None
def _remote_head(self):
"""Origin's HEAD, or None. Writes nothing into the checkout."""
code, out = self._git("ls-remote", "origin", "HEAD")
if code != 0 or not out:
return None
sha = out.split()[0] if out.split() else ""
# The guard: this value came off the network and is about to become a
# git argument. Anything but a bare object id is refused outright.
return sha if RE_SHA.match(sha) else None
def check(self) -> str:
"""Run one check now and return the verdict. Read-only throughout."""
if not shutil.which("git"):
return "unknown"
local = self._local_head()
if not local:
return "unknown" # not a git checkout, or no commits
remote = self._remote_head()
if not remote:
return "unknown" # offline, no origin, or a junk answer
if local == remote:
return "current"
# Do we already hold origin's commit? Only objects we have are
# consulted from here on, so no fetch is ever needed.
code, _ = self._git("cat-file", "-e", remote + "^{commit}",
capture=False)
have = code == 0
before, after = False, False
if have:
# Exit 1 means "not an ancestor", which is an answer, not an error.
code, _ = self._git("merge-base", "--is-ancestor", local, remote,
capture=False)
before = code == 0
code, _ = self._git("merge-base", "--is-ancestor", remote, local,
capture=False)
after = code == 0
return verdict(local, remote, have, before, after)
def _run_check(self):
state = self.check()
with self._lock:
self._pending = state
self._inflight = False
def _collect(self, now: float):
with self._lock:
state, self._pending = self._pending, None
if state is not None:
self.state = state
self.checked_ts = round(now)
def tick(self, now: float):
"""Called from the daemon loop; starts a check when one is due and
adopts the verdict of one that has finished. Never blocks."""
if not self.enabled:
# Turning the setting off clears any standing notice, so the
# glyph disappears rather than lingering with a stale answer.
# A check already running is left to finish and its answer
# dropped here, unread.
self.state, self.checked_ts, self._next = "unknown", None, None
with self._lock:
self._pending = None
return
self._collect(now)
if self._next is None:
self._next = now + FIRST_CHECK_DELAY_S
return
if now < self._next:
return
self._next = now + CHECK_INTERVAL_S
with self._lock:
if self._inflight:
return
self._inflight = True
self._spawn(self._run_check)
# A synchronous spawn has already finished; adopt it now rather
# than a tick later.
self._collect(now)
def snapshot(self) -> dict:
"""What the panel reads. None while nothing has been established."""
if not self.enabled or self.state == "unknown":
return None
return {
"state": self.state,
"available": self.state == "behind",
"checked_ts": self.checked_ts,
}