Files
MoShiro/scripts/stability_wsl.py
T
justme 2e146d4da7
ci/woodpecker/push/main-amd64-build Pipeline was successful
refactor!: rename the project from Monitor to MoShiro [CI]
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
2026-10-07 02:03:25 +08:00

614 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
稳定性实测:真实的初始化、正常关闭、崩溃关闭、链路故障与恢复。
拓扑:Windows 上的 Probe(NativeAOT exe)→ WSL2 里的 Agent → 同一个 WSL 里的 Backend。
Probe↔Agent 走真实的虚拟网络(eth0),Agent↔Backend 走 WSL 里的回环(故障用 tc 注入在 lo 上)。
前提:WSL 里有 Debian(root)和 tc;已发布 linux-x64 的 agent、backend、fleet-enroll(scripts/publish.sh),
以及 Windows 的 probe(dotnet publish backend/probe/src/MoShiro.Probe -c Release -r win-x64 -p:PublishAot=true -o backend/artifacts/aot-probe)。
用法:python scripts/stability_wsl.py [场景名 ...] (不带参数运行全部)
场景:init graceful crash probe_link backend_link silent soak
"""
import ctypes
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
# 要连的是本机的虚拟网络(WSL),不能走环境变量里的 HTTP(S)_PROXY
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROBE_EXE = os.path.join(ROOT, "backend", "artifacts", "aot-probe", "moshiro-probe.exe")
PUB = "/mnt/" + ROOT[0].lower() + ROOT[2:].replace("\\", "/") + "/backend/artifacts/publish"
WORK = os.path.join(os.environ.get("TEMP", "."), "stability")
PORT = 29181
ADMIN_PASSWORD = os.environ.get("MOSHIRO_ADMIN_PASSWORD", "correct horse battery") # 测试用后端的初始管理员密码
RESULTS = []
HIDDEN = subprocess.CREATE_NO_WINDOW # 所有子进程都不弹窗
# ------------------------------------------------------------------ WSL / process helpers
def wsl(cmd, check=True, timeout=120):
r = subprocess.run(["wsl", "-d", "Debian", "-u", "root", "-e", "bash", "-c", cmd], capture_output=True, text=True, timeout=timeout, creationflags=HIDDEN)
if check and r.returncode != 0:
raise RuntimeError(f"wsl failed ({r.returncode}): {cmd}\n{r.stdout}\n{r.stderr}")
return r.stdout.strip()
WSLIP = wsl("ip -4 addr show eth0 | grep -oP '(?<=inet )[0-9.]+'")
BASE = f"http://{WSLIP}:{PORT}"
TOKEN = ""
LAUNCHER = r"""#!/bin/sh
# start.sh <name> <command...>: run detached, append to <name>.log, record the exit code in <name>.exit
name=$1; shift
rm -f /root/mon/$name.exit
setsid -f sh -c 'cd /root/mon; "$@" >> /root/mon/'$name'.log 2>&1; echo $? > /root/mon/'$name'.exit' sh "$@" < /dev/null
"""
def now():
return time.time()
def check(name, ok, detail=""):
RESULTS.append((name, bool(ok), detail))
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" - {detail}" if detail else ""), flush=True)
return ok
def wait_until(cond, timeout, step=0.5):
start = now()
while now() - start < timeout:
try:
if cond():
return now() - start
except Exception:
pass
time.sleep(step)
return None
def api(method, path, body=None, ctype="application/json", timeout=15):
headers = {"Content-Type": ctype}
if TOKEN:
headers["Authorization"] = "Bearer " + TOKEN
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(BASE + path, data=data, method=method, headers=headers)
with OPENER.open(req, timeout=timeout) as resp:
text = resp.read().decode()
return json.loads(text) if text else None
def backend_up():
try:
OPENER.open(BASE + "/healthz", timeout=2)
return True
except Exception:
return False
# ------------------------------------------------------------------ components
def pids(name):
out = wsl(f"pgrep -x {name} || true")
return [int(x) for x in out.split()]
def rss_mb(name):
p = pids(name)
if not p:
return None
return int(wsl(f"ps -o rss= -p {p[0]}")) / 1024
def start_backend():
wsl(f"cd /root/mon && /root/mon/start.sh backend env ASPNETCORE_URLS=http://0.0.0.0:{PORT} MoShiro__DataDirectory=/root/mon/data "
f"MoShiro__InitialAdminUser=admin 'MoShiro__InitialAdminPassword={ADMIN_PASSWORD}' MoShiro__SecureCookies=false ./moshiro-backend")
return wait_until(backend_up, 30)
def start_agent():
wsl("/root/mon/start.sh agent ./moshiro-agent --config /root/mon/agent.json")
return wait_until(lambda: bool(pids("moshiro-agent")), 10)
def stop_proc(name, sig="TERM", timeout=30):
"""Send a signal and wait for the process to exit; returns (seconds, exit code or None)."""
start = now()
wsl(f"pkill -{sig} -x {name} || true")
gone = wait_until(lambda: not pids(name), timeout, 0.2)
code = wsl(f"cat /root/mon/{name.replace('moshiro-', '')}.exit 2>/dev/null || true")
return (now() - start if gone is not None else None), (int(code) if code.strip().lstrip('-').isdigit() else None)
def send_ctrl_break(pid):
"""
Ctrl+Break to a process that owns its own (hidden) console: attach to that console, broadcast the event, detach.
Ctrl+Break rather than Ctrl+C: a console process can inherit "ignore Ctrl+C" from its parent (it does under most job runners),
Ctrl+Break cannot be ignored that way. .NET reports it as SIGQUIT.
"""
from ctypes import wintypes
k = ctypes.WinDLL("kernel32", use_last_error=True)
handler = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)(lambda event: True) # swallow the event in this process
k.FreeConsole()
if not k.AttachConsole(pid):
raise OSError(f"AttachConsole({pid}) failed: {ctypes.get_last_error()}")
k.SetConsoleCtrlHandler(handler, True)
k.GenerateConsoleCtrlEvent(1, 0)
time.sleep(0.3)
k.FreeConsole()
k.SetConsoleCtrlHandler(handler, False)
class Probe:
def __init__(self):
self.proc = None
self.log = os.path.join(WORK, "probe.log")
def start(self):
os.makedirs(WORK, exist_ok=True)
handle = open(self.log, "a")
# 自己的隐藏控制台:这样才能从外面给它发 Ctrl+Break
flags = subprocess.CREATE_NO_WINDOW
self.proc = subprocess.Popen([PROBE_EXE, "--config", os.path.join(WORK, "probe", "probe.json")], stdout=handle, stderr=subprocess.STDOUT, creationflags=flags)
return self.proc.pid
def alive(self):
return self.proc is not None and self.proc.poll() is None
def stop(self, mode="graceful", timeout=20):
if not self.alive():
return 0.0, self.proc.poll() if self.proc else None
start = now()
if mode == "graceful":
send_ctrl_break(self.proc.pid)
else:
self.proc.kill()
try:
code = self.proc.wait(timeout)
except subprocess.TimeoutExpired:
self.proc.kill()
return None, None
return now() - start, code
def suspend(self):
"""Freeze the process (its TCP connections stay open and go silent, like a hung machine)."""
handle = ctypes.windll.kernel32.OpenProcess(0x0800, False, self.proc.pid) # PROCESS_SUSPEND_RESUME
ctypes.windll.ntdll.NtSuspendProcess(handle)
ctypes.windll.kernel32.CloseHandle(handle)
def resume(self):
handle = ctypes.windll.kernel32.OpenProcess(0x0800, False, self.proc.pid)
ctypes.windll.ntdll.NtResumeProcess(handle)
ctypes.windll.kernel32.CloseHandle(handle)
def working_set_mb(self):
if not self.alive():
return None
out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {self.proc.pid}).WorkingSet64"], capture_output=True, text=True, creationflags=HIDDEN).stdout.strip()
return int(out) / 1048576 if out else None
def log_text(self):
return open(self.log, encoding="utf-8", errors="replace").read() if os.path.exists(self.log) else ""
PROBE = Probe()
AGENT_ID = ""
# ------------------------------------------------------------------ environment setup
def setup(fresh=True):
"""Clean state, copy the artifacts, start backend/agent, create the probe identity. The probe is not started."""
global TOKEN, AGENT_ID
PROBE.stop("kill")
wsl("pkill -9 -x moshiro-agent; pkill -9 -x moshiro-backend; tc qdisc del dev eth0 ingress 2>/dev/null; tc qdisc del dev eth0 root 2>/dev/null; tc qdisc del dev lo root 2>/dev/null; tc qdisc del dev lo ingress 2>/dev/null; true")
if fresh:
wsl(f"rm -rf /root/mon && mkdir -p /root/mon && cp {PUB}/backend/linux-x64/* {PUB}/agent/linux-x64/* {PUB}/fleet-enroll/linux-x64/* /root/mon/ && chmod +x /root/mon/moshiro-* /root/mon/fleet-enroll")
wsl("cat > /root/mon/start.sh <<'EOF'\n" + LAUNCHER + "EOF\nchmod +x /root/mon/start.sh")
if os.path.isdir(WORK):
subprocess.run(["cmd", "/c", "rmdir", "/s", "/q", WORK], capture_output=True, creationflags=HIDDEN)
os.makedirs(os.path.join(WORK, "probe"), exist_ok=True)
assert start_backend() is not None, "backend did not start"
if fresh:
req = urllib.request.Request(BASE + "/api/admin/auth/login", data=json.dumps({"username": "admin", "password": ADMIN_PASSWORD}).encode(), headers={"Content-Type": "application/json"}, method="POST")
with OPENER.open(req, timeout=15) as resp:
cookie = resp.headers["Set-Cookie"].split(";")[0]
csrf = json.loads(resp.read())["csrf_token"]
req = urllib.request.Request(BASE + "/api/admin/api-tokens", data=json.dumps({"name": "stab", "scope": "admin"}).encode(), headers={"Content-Type": "application/json", "Cookie": cookie, "X-CSRF-Token": csrf}, method="POST")
TOKEN = json.loads(OPENER.open(req, timeout=15).read())["plaintext"]
join = api("POST", "/api/admin/join-tokens", {"auto_approve": True, "max_uses": 5, "expires_in_hours": 1})["plaintext"]
wsl(f"cd /root/mon && ./fleet-enroll --server http://127.0.0.1:{PORT} --join {join} --name wsl-agent --identity-dir /root/mon/id")
cfg = {"fleet": {"server": f"http://127.0.0.1:{PORT}", "identity_dir": "/root/mon/id"}, "listen": {"bind": "0.0.0.0"}}
wsl("cat > /root/mon/agent.json <<'EOF'\n" + json.dumps(cfg) + "\nEOF")
assert start_agent() is not None
assert wait_until(lambda: api("GET", "/api/admin/agents")["items"][0]["online"], 20) is not None, "agent did not come online"
agent = api("GET", "/api/admin/agents")["items"][0]
AGENT_ID = agent["id"]
if fresh:
api("PATCH", "/api/admin/agents/" + AGENT_ID, {"public_host": WSLIP}, "application/merge-patch+json")
ident = api("POST", "/api/admin/probes", {"name": "home"})
json.dump(ident, open(os.path.join(WORK, "probe", "probe.json"), "w"))
PROBE.start()
assert wait_until(agent_view_probe_connected, 30) is not None, "probe did not connect"
def agent_view():
return api("GET", "/api/admin/agents/" + AGENT_ID)
def agent_view_probe_connected():
return agent_view()["probe_connected"]
def agent_online():
return agent_view()["online"]
def metrics(rng="2m", names="loss.total,loss.up,loss.down,rtt.p50,tcp.rtt.p50,tcp.rtt.max"):
s = api("GET", f"/api/public/lines/{AGENT_ID}/series?range={rng}&step=1s&metrics={names}")
return s["summary"]["metrics"], s["summary"]["samples"]
def gaps(minutes=3):
"""Buckets (10 s) in the last N minutes, after the first data, whose udp sample count is below half of the expected 100."""
s = api("GET", f"/api/public/lines/{AGENT_ID}/series?range={minutes}m&step=10s&metrics=n.udp_echo,n.tcp_echo")
udp = s["values"]["n.udp_echo"]
first = next((i for i, v in enumerate(udp) if v is not None and v >= 50), len(udp)) # buckets before the first data are not gaps
return [i for i, v in enumerate(udp[:-1]) if i > first and (v is None or v < 50)], len(udp)
def stored_chunks():
return len(api("GET", f"/api/admin/raw/chunks?agent_id={AGENT_ID}&limit=1000")["items"])
def healthy():
try:
return agent_online() and agent_view_probe_connected()
except Exception:
return False
def ensure_healthy(timeout=60):
if not backend_up():
start_backend()
if not pids("moshiro-agent"):
start_agent()
if not PROBE.alive():
PROBE.start()
return wait_until(healthy, timeout)
# ------------------------------------------------------------------ scenarios
def scenario_init():
print("== init: cold starts in every order, bad configuration")
# 1. everything down, then start in the opposite order: probe -> agent -> backend (each must retry until its peer is up)
PROBE.stop("kill")
wsl("pkill -9 -x moshiro-agent; pkill -9 -x moshiro-backend; true")
time.sleep(1)
PROBE.start()
time.sleep(3)
start_agent()
time.sleep(3)
t0 = now()
start_backend()
took = wait_until(healthy, 90)
check("probe -> agent -> backend start order converges", took is not None, f"{took:.1f}s after the backend came up" if took else "never became healthy")
chunks_flow = wait_until(lambda: stored_chunks() > 0, 40)
check("data flows after the cold start", chunks_flow is not None)
# 2. backend first, then agent, then probe (the normal order) after a full stop
PROBE.stop("kill")
wsl("pkill -9 -x moshiro-agent; pkill -9 -x moshiro-backend; true")
start_backend()
start_agent()
PROBE.start()
took = wait_until(healthy, 60)
check("backend -> agent -> probe start order converges", took is not None, f"{took:.1f}s" if took else "")
# 3. the probe starts when its agent list is only a stale cache (agents.json wrong): bootstrap is still tried
# 4. bad configuration: clear errors and a non-zero exit code, no crash dump
bad = os.path.join(WORK, "bad.json")
open(bad, "w").write("{ not json")
r = subprocess.run([PROBE_EXE, "--config", bad], capture_output=True, text=True, timeout=20, creationflags=HIDDEN)
check("probe: malformed config exits 1 with a readable error", r.returncode == 1 and "startup failed" in r.stderr + r.stdout, (r.stderr + r.stdout).strip()[:120])
r = subprocess.run([PROBE_EXE, "--config", os.path.join(WORK, "missing.json")], capture_output=True, text=True, timeout=20, creationflags=HIDDEN)
check("probe: missing config exits 1 with the file name", r.returncode == 1 and "missing.json" in r.stderr + r.stdout, (r.stderr + r.stdout).strip()[:120])
out = wsl("cd /root/mon && echo '{ not json' > bad.json && ./moshiro-agent --config bad.json 2>&1; echo exit=$?", check=False)
check("agent: malformed config exits 1", "exit=1" in out and "startup failed" in out, out.strip()[-120:])
out = wsl("cd /root/mon && printf '{\"fleet\":{\"server\":\"http://127.0.0.1:1\",\"identity_dir\":\"/nonexistent\"}}' > noid.json && ./moshiro-agent --config noid.json 2>&1; echo exit=$?", check=False)
check("agent: no identity says to run fleet-enroll first", "exit=1" in out and "fleet-enroll" in out, out.strip()[-140:])
out = wsl(f"cd /root/mon && ./fleet-enroll --server http://127.0.0.1:{PORT} --join bogus --name x --identity-dir /tmp/enroll-x 2>&1; echo exit=$?", check=False)
check("fleet-enroll: a bad join token fails clearly (non-zero exit)", "exit=0" not in out, out.strip()[-140:])
out = wsl(f"cd /root/mon && ./fleet-enroll --server http://127.0.0.1:{PORT} --join bogus --name x --identity-dir /root/mon/id 2>&1; echo exit=$?", check=False)
check("fleet-enroll: refuses to overwrite an existing identity", "exit=0" not in out and "already enrolled" in out, out.strip()[-140:])
ensure_healthy()
def scenario_graceful():
print("== graceful: SIGTERM / Ctrl+Break of each component, then restart")
ensure_healthy()
# probe (Ctrl+Break in its own console)
secs, code = PROBE.stop("graceful")
check("probe exits on Ctrl+Break within 5 s with code 0", secs is not None and secs < 5 and code == 0, f"{secs and round(secs, 2)}s code={code}")
seen = wait_until(lambda: not agent_view_probe_connected(), 10)
check("backend sees the probe disconnect", seen is not None, f"{seen and round(seen, 1)}s")
PROBE.start()
check("probe restarts and reconnects", wait_until(healthy, 30) is not None)
# agent
secs, code = stop_proc("moshiro-agent", "TERM")
log = wsl("tail -5 /root/mon/agent.log")
check("agent exits on SIGTERM within 5 s with code 0", secs is not None and secs < 5 and code == 0, f"{secs and round(secs, 2)}s code={code}")
check("agent logs a clean stop (no crash trace)", "stopping" in log and "Unhandled" not in log, log.splitlines()[-1][:100] if log else "")
seen = wait_until(lambda: not agent_online(), 10)
check("backend sees the agent go offline", seen is not None, f"{seen and round(seen, 1)}s")
start_agent()
check("agent restarts, reconnects to backend and probe", wait_until(healthy, 40) is not None)
# backend
secs, code = stop_proc("moshiro-backend", "TERM", 30)
log = wsl("tail -5 /root/mon/backend.log")
check("backend exits on SIGTERM within 15 s with code 0", secs is not None and secs < 15 and code == 0, f"{secs and round(secs, 2)}s code={code}")
check("backend log has no unhandled exception", "Unhandled" not in log and "Exception" not in log, log.replace(chr(10), ' | ')[-160:])
time.sleep(5)
start_backend()
check("backend restarts, agent and probe converge again", wait_until(healthy, 60) is not None)
ensure_healthy()
def scenario_crash():
print("== crash: kill -9 / taskkill /F of each component, data durability")
ensure_healthy()
time.sleep(25)
before = stored_chunks()
# backend crash: the agent keeps its outbox; after restart nothing acknowledged is lost
t = now()
wsl("pkill -9 -x moshiro-backend")
time.sleep(25) # agent outbox accumulates (2-3 chunk rounds)
start_backend()
back = wait_until(healthy, 60)
check("backend kill -9: agent reconnects and everything converges", back is not None, f"{back and round(back, 1)}s after restart")
time.sleep(20)
after = stored_chunks()
g, n = gaps(3)
check("backend kill -9: chunks buffered by the agent are delivered after the restart (no gap)", not g, f"gaps in {n} buckets: {g}; chunks {before}->{after}")
# agent crash
wsl("pkill -9 -x moshiro-agent")
time.sleep(5)
start_agent()
check("agent kill -9: recovers", wait_until(healthy, 40) is not None)
# probe crash
PROBE.stop("kill")
time.sleep(5)
PROBE.start()
check("probe kill: recovers", wait_until(healthy, 40) is not None)
time.sleep(25)
m, samples = metrics("30s")
check("after all three crashes the line reports correct data (no phantom loss)", (m.get("loss.up") or 0) < 1 and (m.get("loss.down") or 0) < 1, f"loss.up={m.get('loss.up')} loss.down={m.get('loss.down')}")
# crash in the middle of an on-demand test
test = api("POST", f"/api/admin/lines/{AGENT_ID}/tests", {"plan": "hy2_ladder", "params": {"size": 1250}})
time.sleep(12)
PROBE.stop("kill")
state = wait_until(lambda: api("GET", "/api/public/tests/" + test["id"])["status"] in ("failed", "aborted"), 30)
final = api("GET", "/api/public/tests/" + test["id"])
check("probe crash mid-test: the test ends (failed/aborted), it does not hang", state is not None, f"status={final['status']} error={final.get('error_code')}")
PROBE.start()
wait_until(healthy, 40)
test2 = api("POST", f"/api/admin/lines/{AGENT_ID}/tests", {"plan": "hy2_ladder", "params": {"size": 1250}})
done = wait_until(lambda: api("GET", "/api/public/tests/" + test2["id"])["status"] in ("succeeded", "failed", "aborted"), 90, 2)
check("a new test runs to completion after the crash (the agent is not stuck busy)", done is not None and api("GET", "/api/public/tests/" + test2["id"])["status"] == "succeeded", api("GET", "/api/public/tests/" + test2["id"])["status"])
ensure_healthy()
def netem(device, spec, direction="egress", ports=(7443, 7444)):
"""Impair only the data/control ports of the agent (so the rest of WSL is unaffected)."""
clear_netem(device)
if direction == "egress":
wsl(f"tc qdisc add dev {device} root handle 1: prio && tc qdisc add dev {device} parent 1:3 handle 30: netem {spec}")
for p in ports:
wsl(f"tc filter add dev {device} protocol ip parent 1:0 prio 3 u32 match ip sport {p} 0xffff flowid 1:3")
else:
wsl(f"tc qdisc add dev {device} handle ffff: ingress")
for p in ports:
action = "action drop" if spec == "drop" else ""
wsl(f"tc filter add dev {device} parent ffff: protocol ip u32 match ip dport {p} 0xffff {action}")
def clear_netem(device):
wsl(f"tc qdisc del dev {device} root 2>/dev/null; tc qdisc del dev {device} ingress 2>/dev/null; true")
def scenario_probe_link():
print("== probe<->agent link: flapping, long blackhole (half-open), reconnect storms")
ensure_healthy()
time.sleep(20)
mem0 = (PROBE.working_set_mb(), rss_mb("moshiro-agent"))
# 1. flapping: 3 s blackholes every 10 s, 8 times
for _ in range(8):
netem("eth0", "drop", "ingress")
time.sleep(3)
clear_netem("eth0")
time.sleep(7)
check("flapping link: probe still connected afterwards", wait_until(healthy, 30) is not None)
# 2. a 70 s blackhole: both ends must notice or survive, and recover once the path returns
t = now()
netem("eth0", "drop", "ingress")
time.sleep(70)
clear_netem("eth0")
back = wait_until(healthy, 120)
check("70 s blackhole: converges back to healthy", back is not None, f"{back and round(back, 1)}s after the path returned")
time.sleep(35)
m, _ = metrics("30s")
check("after the blackhole the line is measured correctly again", (m.get("loss.total") or 0) < 2, f"loss.total={m.get('loss.total')}")
# 3. reconnect storm: kill the agent 12 times in a row
for _ in range(12):
wsl("pkill -9 -x moshiro-agent; true")
time.sleep(0.5)
start_agent()
time.sleep(2)
check("12 agent restarts in a row: converges", wait_until(healthy, 60) is not None)
time.sleep(30)
g, n = gaps(1)
check("... and the data is flowing again", not g, f"gaps: {g}")
# 4. restart storm of the probe
for _ in range(8):
PROBE.stop("kill")
time.sleep(0.3)
PROBE.start()
time.sleep(2)
check("8 probe restarts in a row: converges", wait_until(healthy, 60) is not None)
time.sleep(40)
mem1 = (PROBE.working_set_mb(), rss_mb("moshiro-agent"))
check("memory stays bounded after the storms", mem1[0] < mem0[0] + 15 and mem1[1] < mem0[1] + 15, f"probe {mem0[0]:.1f}->{mem1[0]:.1f} MB, agent {mem0[1]:.1f}->{mem1[1]:.1f} MB")
ensure_healthy()
def scenario_backend_link():
print("== agent<->backend link: outage buffering, replay, duplicates")
ensure_healthy()
time.sleep(30)
# an outage on loopback (tc on lo: the agent<->backend link) shorter than the buffering capacity
netem("lo", "drop", "ingress", ports=(PORT,))
t = now()
time.sleep(45)
clear_netem("lo")
check("45 s agent<->backend outage: agent stays up and reconnects", wait_until(agent_online, 90) is not None, f"{now() - t:.0f}s")
time.sleep(35)
g, n = gaps(4)
check("data produced during the outage is replayed from the agent's outbox (no gap)", not g, f"gaps in {n} buckets: {g}")
# duplicates: chunk ids are idempotent. The count of stored chunks grows by the expected 2 per 10 s, not more
c0 = stored_chunks()
time.sleep(30)
c1 = stored_chunks()
check("steady state: ~2 chunks / 10 s (udp + tcp), no duplicates", 4 <= c1 - c0 <= 8, f"{c1 - c0} chunks in 30 s")
# the backend is stopped for a while (graceful): the agent keeps collecting, delivers after
stop_proc("moshiro-backend", "TERM", 30)
time.sleep(40)
start_backend()
check("backend down 40 s then up: everything converges", wait_until(healthy, 90) is not None)
time.sleep(35)
g, n = gaps(4)
check("... with no gap in the data", not g, f"gaps: {g}")
ensure_healthy()
def scenario_silent():
print("== silent death: a frozen process keeps its sockets open and says nothing (hung machine, lost power)")
ensure_healthy()
time.sleep(10)
# a frozen probe: the agent must notice and tell the backend, instead of keeping a dead session forever
PROBE.suspend()
t = now()
seen = wait_until(lambda: not agent_view_probe_connected(), 150, 1)
check("frozen probe: the backend learns the probe is gone", seen is not None, f"{seen and round(seen)}s" if seen else "still shown as connected after 150 s")
PROBE.resume()
check("... and when the probe thaws it comes back", wait_until(healthy, 90) is not None)
# a frozen agent: the probe must drop the session and keep retrying; the backend must mark the agent offline
wsl("pkill -STOP -x moshiro-agent")
seen_off = wait_until(lambda: not agent_online(), 150, 1)
check("frozen agent: the backend marks it offline", seen_off is not None, f"{seen_off and round(seen_off)}s" if seen_off else "still online after 150 s")
wsl("pkill -CONT -x moshiro-agent")
check("... and when the agent thaws everything converges", wait_until(healthy, 90) is not None)
# a frozen backend: the agent must notice (WebSocket ping timeout) and reconnect once the backend thaws
wsl("pkill -STOP -x moshiro-backend")
time.sleep(70)
wsl("pkill -CONT -x moshiro-backend")
check("frozen backend (70 s): converges after it thaws", wait_until(healthy, 90) is not None)
time.sleep(30)
g, n = gaps(3)
check("... and the data produced while it was frozen is delivered", not g, f"gaps: {g}")
ensure_healthy()
def scenario_soak():
print("== soak: 8 minutes with random faults; memory, convergence and data continuity")
ensure_healthy()
time.sleep(20)
mem0 = (PROBE.working_set_mb(), rss_mb("moshiro-agent"), rss_mb("moshiro-backend"))
import random
rnd = random.Random(7)
end = now() + 8 * 60
faults = 0
while now() < end:
fault = rnd.choice(["blackhole", "loss", "agent_kill", "probe_kill", "none", "none"])
faults += fault != "none"
if fault == "blackhole":
netem("eth0", "drop", "ingress")
time.sleep(rnd.uniform(1, 6))
clear_netem("eth0")
elif fault == "loss":
netem("eth0", "delay 20ms 5ms loss 5%", "egress")
time.sleep(rnd.uniform(5, 12))
clear_netem("eth0")
elif fault == "agent_kill":
wsl("pkill -9 -x moshiro-agent; true")
time.sleep(1)
start_agent()
elif fault == "probe_kill":
PROBE.stop("kill")
time.sleep(0.5)
PROBE.start()
time.sleep(rnd.uniform(8, 20))
clear_netem("eth0")
check(f"after {faults} random faults everything converges", ensure_healthy(90) is not None)
time.sleep(45)
mem1 = (PROBE.working_set_mb(), rss_mb("moshiro-agent"), rss_mb("moshiro-backend"))
check("memory bounded", mem1[0] < mem0[0] + 15 and mem1[1] < mem0[1] + 20 and mem1[2] < mem0[2] + 40, f"probe {mem0[0]:.0f}->{mem1[0]:.0f}, agent {mem0[1]:.0f}->{mem1[1]:.0f}, backend {mem0[2]:.0f}->{mem1[2]:.0f} MB")
m, _ = metrics("30s")
check("the final minute is measured correctly", (m.get("loss.up") or 0) < 1 and (m.get("loss.down") or 0) < 1, f"{m.get('loss.up')} {m.get('loss.down')}")
SCENARIOS = {
"init": scenario_init,
"graceful": scenario_graceful,
"crash": scenario_crash,
"probe_link": scenario_probe_link,
"backend_link": scenario_backend_link,
"silent": scenario_silent,
"soak": scenario_soak,
}
def main():
names = sys.argv[1:] or list(SCENARIOS)
print(f"wsl ip {WSLIP}")
setup(fresh=True)
for name in names:
try:
SCENARIOS[name]()
except Exception as ex:
check(f"{name} ran to completion", False, repr(ex)[:300])
PROBE.stop("kill")
wsl("pkill -9 -x moshiro-agent; pkill -9 -x moshiro-backend; tc qdisc del dev eth0 root 2>/dev/null; tc qdisc del dev eth0 ingress 2>/dev/null; tc qdisc del dev lo ingress 2>/dev/null; true")
failed = [r for r in RESULTS if not r[1]]
print(f"\n{len(RESULTS) - len(failed)}/{len(RESULTS)} checks passed")
for name, _, detail in failed:
print(f"FAILED: {name} - {detail}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()