Link test: UDP echo sweep, soak and TCP each way, driven by the app
CI / Analyze & Test (push) Successful in 10s
CI / Debug APK (push) Successful in 25s

The tablet's app sockets are the only ones bound to the USB link, so the
measurement has to run in the app. tool/linktest_server.py serves the
Orin end and logs every result with its own TCP_INFO. Startable without
touching the screen: am start ... --es linktest <label>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-11 15:55:52 +09:00
parent f14202047f
commit 6b5fef41c4
5 changed files with 416 additions and 6 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Orin side of the app's link test (lib/linktest.dart). Development tool, not a service.
The tablet's own sockets are the only ones bound to the USB link (`adb shell`
traffic goes over WiFi), so the app drives the test and this end only serves:
7791/tcp down: client sends "<id> <secs>\\n", gets zeros for <secs>
7792/tcp up: client sends "<id>\\n" then data until EOF; gets "<bytes>\\n" back
7793/udp echo
7794/tcp results: one JSON line per connection, appended to the results file
Every TCP test also logs this end's TCP_INFO (retransmits, RTT), which the app
cannot read. Run: python3 linktest_server.py [results.jsonl]
"""
import json
import os
import socket
import struct
import sys
import threading
import time
RESULTS = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/linktest/results.jsonl")
CHUNK = b"\0" * 65536
lock = threading.Lock()
def log(rec):
rec = {"orin_ts": round(time.time(), 3), **rec}
with lock, open(RESULTS, "a") as f:
f.write(json.dumps(rec) + "\n")
print(json.dumps(rec), flush=True)
def tcp_info(sock):
# struct tcp_info: 8 bytes of u8 fields, then u32s; rtt is the 16th, total_retrans the 24th.
raw = sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_INFO, 104)
u = struct.unpack_from("8B24I", raw)
return {"retrans": u[8 + 23], "rtt_ms": u[8 + 15] / 1000, "rto_ms": u[8] / 1000}
def readline(conn):
buf = b""
while not buf.endswith(b"\n") and len(buf) < 256:
b = conn.recv(1)
if not b:
break
buf += b
return buf.decode().strip()
def down(conn, peer):
test_id, secs = readline(conn).split()
end = time.monotonic() + float(secs)
sent = 0
try:
while time.monotonic() < end:
sent += conn.send(CHUNK)
except OSError as e:
log({"id": test_id, "kind": "tcp_down_orin", "error": str(e)})
log({"id": test_id, "kind": "tcp_down_orin", "peer": peer, "bytes": sent, **tcp_info(conn)})
def up(conn, peer):
test_id = readline(conn)
got = 0
t0 = time.monotonic()
while True:
b = conn.recv(65536)
if not b:
break
got += len(b)
secs = time.monotonic() - t0
conn.sendall(f"{got}\n".encode())
log({"id": test_id, "kind": "tcp_up_orin", "peer": peer, "bytes": got, "secs": round(secs, 3),
**tcp_info(conn)})
def results(conn, peer):
data = b""
while True:
b = conn.recv(65536)
if not b:
break
data += b
for line in data.decode().splitlines():
if line.strip():
log({"peer": peer, **json.loads(line)})
def serve_tcp(port, handler):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", port))
s.listen(4)
while True:
conn, addr = s.accept()
def run(conn=conn, peer=addr[0]):
with conn:
try:
handler(conn, peer)
except Exception as e: # a broken test must not stop the server
print(f"{handler.__name__} {peer}: {e}", flush=True)
threading.Thread(target=run, daemon=True).start()
def serve_echo(port):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", port))
while True:
data, addr = s.recvfrom(2048)
s.sendto(data, addr)
if __name__ == "__main__":
os.makedirs(os.path.dirname(RESULTS), exist_ok=True)
for port, h in ((7791, down), (7792, up), (7794, results)):
threading.Thread(target=serve_tcp, args=(port, h), daemon=True).start()
print(f"linktest server: tcp 7791 down, 7792 up, 7794 results; udp 7793 echo; -> {RESULTS}", flush=True)
serve_echo(7793)