Something went wrong. Try again.
Backend environment for match hosting for lance.blue
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412#!/usr/bin/env python3"""Drive a running match over CDP and collect input-to-pixel latency.
scripts/perf/latency-pilot.py <debug-port> <out-prefix> [url-substring]
Example:
./scripts/arena.sh up bench-4x4 --latency 20ms google-chrome --headless=new --remote-debugging-port=9300 \\ --user-data-dir=/tmp/latency-pilot-chrome --no-first-run \\ --no-default-browser-check --disable-gpu \\ http://localhost:8080/did:plc:localdev/ python3 scripts/perf/latency-pilot.py 9300 /tmp/latency-pilot
**Open a seat's path, never the bare root.** `container/init/40-render-config.sh`clones one Suramadu app per seat from the `/megamek` template and then deletesthe template, so `webFolder` - and with it our index.html and perf.js - existsonly under `/<did>/`. The bare `/` is a stub app that serves the war's ownindex, which loads no perf.js, and `window.__arenaPerf` is then undefined for areason that has nothing to do with the match. `./scripts/arena.sh up` prints theseat path; the local stub's is `did:plc:localdev`.
suramadu/web/perf.js records four timestamped points - input, WebSocket send,WebSocket receive, paint - behind a `?perf=1` opt-in, and exposes them through`window.__arenaPerf.dump()`. TODO.md calls this "written, not verified": thering buffer and loop pairing were exercised under node against a stub DOM, butnothing has driven it against a real match in a real browser. This script isthat drive: find the seat's page over CDP, load it with `?perf=1` if it is notalready, wait for the page to settle, generate some scripted mouse input, thenpull the dump and reduce it to percentiles.
It does not start Chrome or the match - both are cheap to script badly and easyto get wrong (see helm/CLAUDE.md's headless Chrome section for the invocationthis was tested against). It expects a page already open, matching`url-substring` if given, in a Chrome already listening on `debug-port` -the same contract tests/screenshot.py uses.
Writes two files:
<out-prefix>-perf.json the raw dump() result, verbatim <out-prefix>-summary.json n/p50/p90/p99/max per stage, computed here rather than trusting dump()'s own summary, which only carries p50/p90/max
Exit codes: 0 on a completed run (even with zero loops paired - that is afinding, not a failure), 1 on a CDP or usage error, 2 if window.__arenaPerfnever appeared after navigating with ?perf=1."""
import argparseimport jsonimport mathimport osimport sysimport timeimport urllib.parseimport urllib.request
# --- CDP client --------------------------------------------------------------# Shared with scripts/perf/startup.py.sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))from cdp import CDP, find_page # noqa: E402
# --- driving the page ---------------------------------------------------------
def with_perf_param(url): parts = urllib.parse.urlsplit(url) query = urllib.parse.parse_qsl(parts.query, keep_blank_values=True) if any(k == "perf" for k, _ in query): return url query.append(("perf", "1")) return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query)))
class PerfNotFound(Exception): """window.__arenaPerf never appeared after navigating with ?perf=1."""
def ensure_perf_loaded(cdp, current_url, nav_timeout): """Navigate with ?perf=1 unless the instrument is already live.
An instrument that is already live has been recording since the page opened, and its ring buffer is not ours. Clearing it is what makes a second run on the same page a second *measurement* rather than the first one plus some: without it, consecutive runs against one match returned summaries identical to the decimal from the third onwards, because each dump was reducing a superset of the records before it. Two comparisons were lost to that before it was noticed.""" if cdp.evaluate("typeof window.__arenaPerf !== 'undefined'"): cdp.evaluate("window.__arenaPerf.reset(), 1") return cdp.call("Page.enable") target = with_perf_param(current_url) print(f"navigating to {target}", file=sys.stderr) cdp.call("Page.navigate", {"url": target}, timeout=nav_timeout) if cdp.wait_for_event("Page.loadEventFired", timeout=nav_timeout) is None: # Best effort: some pages never settle to a single load event # (webswing keeps opening frames). Poll readyState instead. deadline = time.monotonic() + nav_timeout while time.monotonic() < deadline: if cdp.evaluate("document.readyState") == "complete": break time.sleep(0.5) if not cdp.evaluate("typeof window.__arenaPerf !== 'undefined'"): raise PerfNotFound( f"window.__arenaPerf never appeared after navigating to {target}" )
def canvas_rect(cdp): rect = cdp.evaluate( "(function(){" "var c = document.querySelector('canvas.suramadu-canvas');" "if (!c) return null;" "var r = c.getBoundingClientRect();" "return {x: r.x, y: r.y, w: r.width, h: r.height};" "})()" ) return rect
def dispatch_mouse(cdp, kind, x, y, button="left", click_count=0): cdp.call( "Input.dispatchMouseEvent", { "type": kind, "x": x, "y": y, "button": button, "clickCount": click_count, }, )
def drive_interaction(cdp, rect): """Drive the interactions a player actually spends their time on.
A pan, then a hover sweep, then a grid of clicks. The hover is the point: crossing hex after hex is what a player does most and what feels laggy when it is, and the original routine never exercised it - eight mousemoves, all inside a drag. This records roughly six times the input per run, which is also what makes a percentile mean anything.
Real trusted input, dispatched through the same CDP verb devtools itself uses - not synthesized DOM events - because perf.js listens with {capture: true} on window and records event.timeStamp, which only a trusted event carries meaningfully. """ if rect: cx, cy = rect["x"] + rect["w"] / 2, rect["y"] + rect["h"] / 2 w, h = rect["w"], rect["h"] else: cx, cy = 640, 400 w, h = 800, 600
start = (cx - w * 0.2, cy) end = (cx + w * 0.2, cy)
# Pan: press, a few dragged mousemoves, release. dispatch_mouse(cdp, "mouseMoved", *start) dispatch_mouse(cdp, "mousePressed", *start) steps = 6 for i in range(1, steps + 1): t = i / steps x = start[0] + (end[0] - start[0]) * t y = start[1] + (end[1] - start[1]) * t dispatch_mouse(cdp, "mouseMoved", x, y) time.sleep(0.03) dispatch_mouse(cdp, "mouseReleased", *end)
time.sleep(0.2)
# Hover sweep. This is the interaction a player spends most of their time # doing and the one that feels laggy when it is: the cursor crosses hex # after hex, and each crossing is a highlight, a tooltip and a repaint. # A pan and two clicks never exercised it - the old routine sent eight # mousemoves, all inside a drag. # # 30ms apart because that is roughly a hand moving a mouse, and because # perf.js ignores moves closer together than MOUSEMOVE_MIN_GAP_MS (20ms) # and the client coalesces its input on a 20ms flush. Faster than this # measures the coalescer rather than the client. for i in range(18): t = i / 17 x = cx - w * 0.3 + w * 0.6 * t y = cy - h * 0.18 + h * 0.36 * (0.5 - abs(t - 0.5)) dispatch_mouse(cdp, "mouseMoved", x, y) time.sleep(0.03)
time.sleep(0.2)
# Clicks, spread rather than twice on the same hex. Selecting a unit # redraws the unit display as well as the board, which is the most # expensive thing a click can ask for and the one worth timing; clicking # bare terrain is the cheap case. Spreading them samples both without # needing to know where anything is. # A grid rather than a handful. Four spread points found a unit three times # in fifty-two on a 16-unit board - most clicks landed on bare terrain, # which is the cheap case, so the expensive one was barely sampled. There # is no way to ask the page where the units are without reading the board # out of the client, so this covers the middle of it densely enough that # some clicks land on something and the reply sizes say which did. grid = [(fx, fy) for fx in (-0.24, -0.08, 0.08, 0.24) for fy in (-0.16, 0.0, 0.16)] for fx, fy in grid: click_x, click_y = cx + w * fx, cy + h * fy dispatch_mouse(cdp, "mouseMoved", click_x, click_y) time.sleep(0.05) dispatch_mouse(cdp, "mousePressed", click_x, click_y, click_count=1) dispatch_mouse(cdp, "mouseReleased", click_x, click_y, click_count=1) time.sleep(0.18)
# --- reducing the dump ---------------------------------------------------------# Mirrors the pairing in suramadu/web/perf.js's loops(): the pilot computes its# own percentiles (including p99, which dump()'s own summary does not carry)# rather than trusting the page's arithmetic - the point of an independent# pilot is to check the instrument, not to relay it.
LOOP_WINDOW_MS = 2000
def input_time(rec): et = rec.get("et") t = rec["t"] if isinstance(et, int | float) and et > 0 and et <= t: return et return t
def pair_loops(records): ins = [r for r in records if r["k"] == "in"] tx = [r for r in records if r["k"] == "tx"] rx = [r for r in records if r["k"] == "rx"] px = [r for r in records if r["k"] == "px"]
out = [] ti = ri = pi = 0 for rec in ins: at = input_time(rec) while ti < len(tx) and tx[ti]["t"] < at: ti += 1 if ti >= len(tx): break while ri < len(rx) and rx[ri]["t"] < tx[ti]["t"]: ri += 1 if ri >= len(rx): break while pi < len(px) and px[pi]["t"] < rx[ri]["t"]: pi += 1 if pi >= len(px): break if px[pi]["t"] - at > LOOP_WINDOW_MS: continue out.append( { "type": rec["type"], "inputToSend": tx[ti]["t"] - at, "sendToRecv": rx[ri]["t"] - tx[ti]["t"], "recvToPaint": px[pi]["t"] - rx[ri]["t"], "inputToPaint": px[pi]["t"] - at, } ) return out
def percentile(sorted_values, p): if not sorted_values: return None idx = min(len(sorted_values) - 1, max(0, math.ceil(p * len(sorted_values)) - 1)) return sorted_values[idx]
def stage_stats(loops, key): values = sorted(loop[key] for loop in loops) return { "n": len(values), "p50": percentile(values, 0.5), "p90": percentile(values, 0.9), "p99": percentile(values, 0.99), "max": values[-1] if values else None, }
def build_summary(dump): records = dump.get("records", []) loops = pair_loops(records) return { "records": len(records), "dropped": dump.get("dropped"), "drawHook": dump.get("drawHook"), "canvas": dump.get("canvas"), "loopWindowMs": LOOP_WINDOW_MS, "loops": len(loops), "inputToSend": stage_stats(loops, "inputToSend"), "sendToRecv": stage_stats(loops, "sendToRecv"), "recvToPaint": stage_stats(loops, "recvToPaint"), "inputToPaint": stage_stats(loops, "inputToPaint"), }
def _exit(code, message): print(message, file=sys.stderr) return code
# --- main ---------------------------------------------------------------------
def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("port", type=int, help="Chrome's --remote-debugging-port") ap.add_argument("prefix", help="output path prefix, e.g. /tmp/latency-pilot") ap.add_argument( "match", nargs="?", default="", help="substring to pick the page among /json/list (default: any non-blank page)", ) ap.add_argument("--host", default="localhost", help="CDP host (default: localhost)") ap.add_argument( "--settle", type=float, default=2.0, help="seconds to wait after load before driving input", ) ap.add_argument( "--after", type=float, default=1.5, help="seconds to wait after input before dumping", ) ap.add_argument( "--nav-timeout", type=float, default=30.0, help="seconds to wait for navigation to settle", ) args = ap.parse_args()
try: page = find_page(args.host, args.port, args.match) except Exception as exc: # noqa: BLE001 - reported to the caller, not swallowed return _exit(1, f"error: {exc}")
ws_url = page["webSocketDebuggerUrl"] ws_path = ws_url.split(f"{args.port}", 1)[1] print(f"attached to {page['url']}", file=sys.stderr)
cdp = CDP(args.host, args.port, ws_path) cdp.call("Runtime.enable")
try: ensure_perf_loaded(cdp, page["url"], args.nav_timeout) except PerfNotFound as exc: return _exit(2, f"error: {exc}") except Exception as exc: # noqa: BLE001 return _exit(1, f"error: {exc}")
print(f"settling for {args.settle}s", file=sys.stderr) time.sleep(args.settle)
rect = canvas_rect(cdp) if rect is None: print( "no canvas.suramadu-canvas found; using viewport fallback coordinates", file=sys.stderr, ) drive_interaction(cdp, rect)
print(f"waiting {args.after}s for round trips and paint", file=sys.stderr) time.sleep(args.after)
try: dump_json = cdp.evaluate( "JSON.stringify(window.__arenaPerf.dump())", timeout=30 ) except Exception as exc: # noqa: BLE001 return _exit(1, f"error reading dump(): {exc}") if dump_json is None: return _exit(2, "window.__arenaPerf.dump() returned nothing")
dump = json.loads(dump_json)
perf_path = f"{args.prefix}-perf.json" with open(perf_path, "w") as fh: json.dump(dump, fh, indent=2) fh.write("\n")
summary = build_summary(dump) summary_path = f"{args.prefix}-summary.json" with open(summary_path, "w") as fh: json.dump(summary, fh, indent=2) fh.write("\n")
print( f"wrote {perf_path} ({dump.get('written', 0)} records written, {dump.get('dropped', 0)} dropped)" ) print(f"wrote {summary_path}") print(f"drawHook: {dump.get('drawHook')} loops paired: {summary['loops']}") for stage in ("inputToSend", "sendToRecv", "recvToPaint", "inputToPaint"): s = summary[stage] print( f" {stage:>12}: n={s['n']} p50={s['p50']} p90={s['p90']} p99={s['p99']} max={s['max']} (ms)" )
return 0
if __name__ == "__main__": sys.exit(main())