299 lines
11 KiB
Python
Executable File
299 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
stress_run.py — Orchestrator for the streaming-chain stress matrix (stress.py).
|
|
|
|
Where run_e2e.sh drives scenarios.py for *correctness*, this drives
|
|
STRESS_CASES for *capacity*. Per case it:
|
|
|
|
1. generates the FileReader input + MARTe app cfg + 1..M StreamHub cfgs
|
|
(reusing gen_data / gen_cfg unchanged — a stress case is a scenario superset),
|
|
2. launches the server stack:
|
|
* "hub" shape: 1 MARTe UDPStreamer feed + 1 StreamHub, then N
|
|
parallel chain-clients (stress mode) on that one hub,
|
|
* "ds_fanout" shape: 1 MARTe feed + M independent StreamHubs all
|
|
subscribing to the same UDPStreamer (unicast fan-out), 1 client each,
|
|
3. drives the clients in stress mode (liveness + sustained zoom-latency),
|
|
4. snapshots CPU / peak-RSS of marte and every hub *while alive* (proc_perf),
|
|
5. evaluates the per-case gate (survival + liveness hard gates; RSS + zoom-p95
|
|
soft gates against the case's ceilings),
|
|
|
|
and writes stress_results.json (one record per case, carrying the axis/level for
|
|
later scaling-curve plots). Server binaries + LD_LIBRARY_PATH come from the
|
|
caller (run_stress.sh sources env.sh); this module only orchestrates.
|
|
"""
|
|
import argparse
|
|
import copy
|
|
import json
|
|
import os
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import gen_cfg # noqa: E402
|
|
import gen_data # noqa: E402
|
|
import proc_perf # noqa: E402
|
|
import stress as ST # noqa: E402
|
|
|
|
# WS port probing bases. StreamHub does not set SO_REUSEADDR, so a leftover
|
|
# listener (e.g. an orphaned hub from an aborted run) keeps a fixed port wedged;
|
|
# we therefore probe for genuinely-free ports at launch rather than trusting a
|
|
# static assignment. ds_fanout hubs probe from one base, single hubs from another.
|
|
FANOUT_WS_BASE = 9000
|
|
SINGLE_WS_BASE = 9100
|
|
|
|
|
|
def _popen(cmd, log_path):
|
|
"""Launch cmd in its own session, stdout+stderr → log_path."""
|
|
f = open(log_path, "w")
|
|
return subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT,
|
|
start_new_session=True), f
|
|
|
|
|
|
def _free_ports(n, base):
|
|
"""Find n free TCP ports on 127.0.0.1 at/above base.
|
|
|
|
Probed ports are closed and handed straight to the hub launch; the race
|
|
window is negligible for this sequential localhost harness, and probing is
|
|
what makes the suite robust to leftover wedged listeners."""
|
|
found = []
|
|
cand = base
|
|
while len(found) < n and cand < base + 1000:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
try:
|
|
s.bind(("127.0.0.1", cand))
|
|
found.append(cand)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
s.close()
|
|
cand += 1
|
|
if len(found) < n:
|
|
raise RuntimeError(f"could not find {n} free ports from {base}")
|
|
return found
|
|
|
|
|
|
def _hub_ports(case):
|
|
st = case["stress"]
|
|
if case["shape"] == "ds_fanout":
|
|
return _free_ports(st["hubs"], FANOUT_WS_BASE)
|
|
return _free_ports(1, SINGLE_WS_BASE)
|
|
|
|
|
|
def _teardown(procs_files):
|
|
for p, _f in procs_files:
|
|
if p.poll() is None:
|
|
try:
|
|
os.killpg(os.getpgid(p.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
deadline = time.time() + 5
|
|
for p, _f in procs_files:
|
|
try:
|
|
p.wait(timeout=max(0.1, deadline - time.time()))
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
for _p, f in procs_files:
|
|
try:
|
|
f.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def run_case(case, bins, work, out):
|
|
sid = case["id"]
|
|
st = case["stress"]
|
|
input_bin = os.path.join(work, f"sinput_{sid}.bin")
|
|
marte_cfg = os.path.join(work, f"sm_{sid}.cfg")
|
|
|
|
gen_data.write_input(case, input_bin)
|
|
gen_cfg.write_marte_cfg(case, marte_cfg, input_bin)
|
|
|
|
ports = _hub_ports(case)
|
|
hub_pf = [] # (proc, file)
|
|
for i, p in enumerate(ports):
|
|
hc = copy.deepcopy(case)
|
|
hc["ws_port"] = p
|
|
hcfg = os.path.join(work, f"sh_{sid}_{i}.cfg")
|
|
gen_cfg.write_hub_cfg(hc, hcfg)
|
|
proc, f = _popen([bins["hub"], "-cfg", hcfg],
|
|
os.path.join(out, f"shub_{sid}_{i}.log"))
|
|
hub_pf.append((proc, f))
|
|
time.sleep(1.0)
|
|
|
|
marte_proc, marte_f = _popen(
|
|
[bins["marte"], "-l", "RealTimeLoader", "-f", marte_cfg, "-s", "Running"],
|
|
os.path.join(out, f"smarte_{sid}.log"))
|
|
time.sleep(1.5)
|
|
|
|
dur, reqrate = st["dur"], st["reqrate"]
|
|
# clean stale client outputs so a missing file means "client failed".
|
|
for i in range(max(st["clients"], st["hubs"])):
|
|
sp = os.path.join(work, f"stress_{sid}_c{i}.json")
|
|
if os.path.exists(sp):
|
|
os.remove(sp)
|
|
|
|
client_pf = []
|
|
if case["shape"] == "ds_fanout":
|
|
targets = [(i, ports[i]) for i in range(len(ports))]
|
|
else:
|
|
targets = [(i, ports[0]) for i in range(st["clients"])]
|
|
for cid, wsport in targets:
|
|
cmd = [bins["client"], "-mode", "stress",
|
|
"-hub", f"127.0.0.1:{wsport}", "-scenario", sid,
|
|
"-clientid", str(cid), "-dur", str(dur),
|
|
"-reqrate", str(reqrate), "-out", work,
|
|
"-timeout", f"{int(dur) + 90}s"]
|
|
proc, f = _popen(cmd, os.path.join(out, f"sclient_{sid}_c{cid}.log"))
|
|
client_pf.append((proc, f))
|
|
|
|
deadline = time.time() + dur + 60
|
|
for proc, _f in client_pf:
|
|
try:
|
|
proc.wait(timeout=max(1.0, deadline - time.time()))
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
for _p, f in client_pf:
|
|
f.close()
|
|
|
|
# survival + perf must be read while the stack is still alive.
|
|
survival = (marte_proc.poll() is None
|
|
and all(h.poll() is None for h, _ in hub_pf))
|
|
perf_marte = proc_perf.snapshot(marte_proc.pid)
|
|
perf_hubs = [proc_perf.snapshot(h.pid) for h, _ in hub_pf]
|
|
|
|
_teardown([(marte_proc, marte_f)] + hub_pf)
|
|
|
|
clients = []
|
|
for cid, _ in targets:
|
|
jp = os.path.join(work, f"stress_{sid}_c{cid}.json")
|
|
if os.path.exists(jp):
|
|
with open(jp) as f:
|
|
clients.append(json.load(f))
|
|
|
|
return _evaluate(case, survival, perf_marte, perf_hubs, clients)
|
|
|
|
|
|
def _rss_mb(rec):
|
|
return rec.get("peak_rss_kb", 0) / 1024.0 if rec.get("avail") else 0.0
|
|
|
|
|
|
def _evaluate(case, survival, perf_marte, perf_hubs, clients):
|
|
g = case["stress"].get("gate", {})
|
|
fails = []
|
|
|
|
if not survival:
|
|
fails.append("server process died during run")
|
|
if not clients:
|
|
fails.append("no client results recorded")
|
|
|
|
min_frames = g.get("min_frames", 1)
|
|
for c in clients:
|
|
cid = c.get("clientId")
|
|
if c.get("frames", 0) < min_frames:
|
|
fails.append(f"c{cid} frames {c.get('frames')} < {min_frames}")
|
|
if not c.get("monotonic", False):
|
|
fails.append(f"c{cid} non-monotonic time axis")
|
|
if not c.get("wallclock", False):
|
|
fails.append(f"c{cid} non-wallclock timestamps")
|
|
|
|
marte_rss = _rss_mb(perf_marte)
|
|
if "max_marte_rss_mb" in g and marte_rss > g["max_marte_rss_mb"]:
|
|
fails.append(f"marte peakRSS {marte_rss:.0f}MB > {g['max_marte_rss_mb']}MB")
|
|
hub_rss_max = max((_rss_mb(h) for h in perf_hubs), default=0.0)
|
|
if "max_hub_rss_mb" in g and hub_rss_max > g["max_hub_rss_mb"]:
|
|
fails.append(f"hub peakRSS {hub_rss_max:.0f}MB > {g['max_hub_rss_mb']}MB")
|
|
|
|
p95s = [c["zoomP95ms"] for c in clients if c.get("zoomCount", 0) > 0]
|
|
zoom_p95 = max(p95s) if p95s else 0.0
|
|
p50s = [c["zoomP50ms"] for c in clients if c.get("zoomCount", 0) > 0]
|
|
zoom_p50 = max(p50s) if p50s else 0.0
|
|
if "max_zoom_p95_ms" in g and zoom_p95 > g["max_zoom_p95_ms"]:
|
|
fails.append(f"zoom p95 {zoom_p95:.0f}ms > {g['max_zoom_p95_ms']}ms")
|
|
|
|
st = case["stress"]
|
|
return {
|
|
"id": case["id"], "shape": case["shape"],
|
|
"axis": st["axis"], "level": st["level"],
|
|
"status": "PASS" if not fails else "FAIL",
|
|
"survival": survival,
|
|
"clients": len(clients),
|
|
"min_frames": min(((c.get("frames", 0)) for c in clients), default=0),
|
|
"marte_cpu_s": perf_marte.get("cpu_s", 0.0),
|
|
"marte_rss_mb": round(marte_rss, 1),
|
|
"hub_cpu_s": round(sum(h.get("cpu_s", 0.0) for h in perf_hubs), 2),
|
|
"hub_rss_mb": round(hub_rss_max, 1),
|
|
"zoom_count": sum(c.get("zoomCount", 0) for c in clients),
|
|
"zoom_fail": sum(c.get("zoomFail", 0) for c in clients),
|
|
"zoom_p50_ms": round(zoom_p50, 1),
|
|
"zoom_p95_ms": round(zoom_p95, 1),
|
|
"fails": fails,
|
|
}
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description="Run the streaming-chain stress matrix")
|
|
p.add_argument("--marte", required=True, help="MARTeApp.ex path")
|
|
p.add_argument("--hub", required=True, help="StreamHub.ex path")
|
|
p.add_argument("--client", required=True, help="chain-client path")
|
|
p.add_argument("--work", required=True, help="scratch dir")
|
|
p.add_argument("--out", required=True, help="report/log dir")
|
|
p.add_argument("--only", default="", help="run a single case id")
|
|
p.add_argument("--axis", default="", help="run only cases on this axis")
|
|
args = p.parse_args()
|
|
|
|
os.makedirs(args.work, exist_ok=True)
|
|
os.makedirs(args.out, exist_ok=True)
|
|
bins = {"marte": args.marte, "hub": args.hub, "client": args.client}
|
|
|
|
cases = ST.STRESS_CASES
|
|
if args.only:
|
|
cases = [c for c in cases if c["id"] == args.only]
|
|
if args.axis:
|
|
cases = [c for c in cases if c["stress"]["axis"] == args.axis]
|
|
if not cases:
|
|
print("no stress cases selected", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
results = []
|
|
for c in cases:
|
|
errs = ST.validate_case(c)
|
|
if errs:
|
|
print(f"══ {c['id']}: INVALID {errs} ══")
|
|
results.append({"id": c["id"], "axis": c["stress"]["axis"],
|
|
"level": c["stress"]["level"], "status": "FAIL",
|
|
"fails": errs})
|
|
continue
|
|
print(f"\n══ stress {c['id']} ({c['shape']} {c['stress']['axis']}="
|
|
f"{c['stress']['level']}) ══")
|
|
rec = run_case(c, bins, args.work, args.out)
|
|
results.append(rec)
|
|
tag = rec["status"]
|
|
print(f" {tag} frames>={rec.get('min_frames')} "
|
|
f"marteCPU={rec.get('marte_cpu_s', 0):.1f}s "
|
|
f"marteRSS={rec.get('marte_rss_mb', 0):.0f}MB "
|
|
f"hubRSS={rec.get('hub_rss_mb', 0):.0f}MB "
|
|
f"zoom p50/p95={rec.get('zoom_p50_ms', 0):.0f}/"
|
|
f"{rec.get('zoom_p95_ms', 0):.0f}ms")
|
|
if rec.get("fails"):
|
|
for fmsg in rec["fails"]:
|
|
print(f" - {fmsg}")
|
|
|
|
overall = bool(results) and all(r["status"] == "PASS" for r in results)
|
|
doc = {"overall": "PASS" if overall else "FAIL", "cases": results}
|
|
rp = os.path.join(args.out, "stress_results.json")
|
|
with open(rp, "w") as f:
|
|
json.dump(doc, f, indent=2)
|
|
npass = sum(r["status"] == "PASS" for r in results)
|
|
print(f"\nstress_results.json: {npass}/{len(results)} pass → {doc['overall']}")
|
|
sys.exit(0 if overall else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|