open-standards/joule-code/joule-code-rs/crates/joule-moq-interop/scripts/public.py
Transaction Science 7f0e2d61f6 joule-moq-relay: an upstream PUBLISH_DONE waits for the streams its Stream Count names
A publisher's PUBLISH_DONE and its data streams are not ordered with each other
(draft-ietf-moq-transport-22 §9.9). The relay ended the track downstream on the
message and dropped the alias, so a stream still in flight was lost: an -18
test client over WebTransport saw PUBLISH_DONE and no object. The relay now
counts each upstream data stream as its reading ends and, after PUBLISH_DONE,
waits until it has read the count, or three seconds; 0 and the unknown count
end at once. publish_done_streams tests the order at -22, -18 (QUIC and
WebTransport) and -16, a count no stream meets, and 0 and 2^64-1.

The -18 and -16 bridges log each message in and out at debug level, and the
interop client answers requests a namespace-only publisher is sent (a
TRACK_STATUS probe) with DOES_NOT_EXIST. scripts/clients.sh runs other
implementations' interop test clients against the relay on loopback;
public.py keeps each run's debug log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 07:53:57 -04:00

120 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""joule-moq-interop against the public relays an interop runner registry lists.
python3 -I scripts/public.py <joule-moq-interop binary> <implementations.json> <out dir> [--receipts]
For each relay endpoint in the registry (`roles.relay.remote[]`, endpoints marked
inactive skipped) and each draft both it and joule-moq speak (22, 18, 16), it runs the
runner's test cases (TAP 14, one process per endpoint and draft) and, with
`--receipts`, the wai-receipts scenario; it writes each run's TAP to <out dir> and one
line per run to <out dir>/results.jsonl. It reaches the public internet: nothing runs it
in CI. Each run's debug log goes beside its TAP. Standard library only.
"""
import json
import os
import re
import subprocess
import sys
import time
OURS = {22, 18, 16}
def drafts_of(entry):
out = set()
for d in entry.get("draft_versions", []):
m = re.fullmatch(r"draft-(\d+)", d)
if m:
out.add(int(m.group(1)))
return out
def parse_tap(text):
"""(test name, ok, skip reason, YAML lines) for each test point."""
points = []
cur = None
for line in text.splitlines():
m = re.match(r"^(ok|not ok) \d+ - (\S+)(?: # SKIP (.*))?$", line)
if m:
cur = {"test": m.group(2), "ok": m.group(1) == "ok", "skip": m.group(3), "diag": {}}
points.append(cur)
continue
m = re.match(r"^ ([a-z_0-9]+): (.*)$", line)
if m and cur is not None:
try:
cur["diag"][m.group(1)] = json.loads(m.group(2))
except ValueError:
cur["diag"][m.group(1)] = m.group(2)
return points
def main():
if len(sys.argv) < 4:
print(__doc__, file=sys.stderr)
return 2
binary, registry, out = sys.argv[1], sys.argv[2], sys.argv[3]
receipts = "--receipts" in sys.argv[4:]
os.makedirs(out, exist_ok=True)
reg = json.load(open(registry))
lines = []
for name, entry in sorted(reg["implementations"].items()):
relay = entry.get("roles", {}).get("relay", {})
for ep in relay.get("remote", []):
if ep.get("status", "active") == "inactive":
continue
url = ep["url"]
transport = ep.get("transport", "quic" if url.startswith("moqt://") else "webtransport")
if transport == "quic" and url.startswith("https://"):
# The registry names some native QUIC endpoints with https://; the moqt
# URI is the same authority and path.
url = "moqt://" + url[len("https://"):]
for d in sorted(drafts_of(entry) & OURS, reverse=True):
# The runner's tests; then wai-receipts by namespace, and, where that does
# not pass, by PUBLISH and with no per-object properties, to find which
# part the relay does not carry.
runs = [(None, [])]
if receipts:
runs.append(("wai-receipts", []))
for t, extra in runs + ([] if not receipts else [("wai-receipts", ["--flow", "publish"]), ("wai-receipts", ["--no-object-properties"])]):
if extra and lines and lines[-1].get("test") == "wai-receipts" and lines[-1]["exit"] == 0 and lines[-1]["relay"] == name and lines[-1]["draft"] == d and lines[-1]["transport"] == transport and not lines[-1].get("extra"):
continue
label = f"{name}_{transport}_d{d}" + (f"_{t}" if t else "") + "".join("_" + x.strip("-") for x in extra if x.startswith("--"))
cmd = [binary, "--verbose", "--relay", url, "--draft", str(d), "--timeout-scale", "3"] + extra
if ep.get("tls_disable_verify"):
cmd.append("--tls-disable-verify")
if t:
cmd += ["--test", t, "--json", os.path.join(out, label + ".json")]
start = time.time()
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
tap, log, code = p.stdout, p.stderr, p.returncode
except subprocess.TimeoutExpired as e:
text = lambda x: (x or b"").decode(errors="replace") if isinstance(x, bytes) else (x or "")
tap, log, code = text(e.stdout), text(e.stderr), -1
open(os.path.join(out, label + ".tap"), "w").write(tap)
# The session's messages and the bridge's notes, at debug level.
open(os.path.join(out, label + ".log"), "w").write(log)
rec = {
"test": t,
"extra": extra,
"relay": name,
"url": url,
"transport": transport,
"draft": d,
"exit": code,
"seconds": round(time.time() - start, 1),
"points": parse_tap(tap),
"when": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
lines.append(rec)
summary = " ".join(f"{x['test']}={'skip' if x['skip'] else ('ok' if x['ok'] else 'FAIL')}" for x in rec["points"])
print(f"{label}: exit {code} {summary}", flush=True)
with open(os.path.join(out, "results.jsonl"), "w") as f:
for r in lines:
f.write(json.dumps(r) + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())