Skip to content

Commit 816d154

Browse files
authored
refactor: port typing-lag CI test to the v2 socket client (#71)
This was the last CI-gated consumer of the v1 line-protocol client (tests/cmux.py). Points the import at tests_v2/cmux.py instead and ports the RawSocketClient used for the tight simulate_shortcut latency loop from v1 plaintext framing to v2's one-JSON-object-per-line protocol. The warm-up + measured-loop shape is unchanged (5 warm-up calls, then N timed round trips via perf_counter, same optional inter-call delay), so latency thresholds stay comparable to prior CI runs. list_workspaces()/list_surfaces() row shapes matched exactly between v1 and v2 clients, so the test's tuple destructuring needed no changes. seed_history() needed a small local send_line() helper since the v2 client mirrors most of v1's API but doesn't ship that one convenience method — added to the test file rather than tests_v2/cmux.py to keep the diff scoped to this one file. Part of the v1-protocol-deletion sequence: after this, no CI-gated code depends on the v1 client.
1 parent 475aa2f commit 816d154

1 file changed

Lines changed: 60 additions & 15 deletions

File tree

tests/test_workspace_churn_up_arrow_lag.py

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from __future__ import annotations
2121

22+
import json
2223
import os
2324
import select
2425
import socket
@@ -31,7 +32,14 @@
3132
from pathlib import Path
3233
from typing import Callable, Optional
3334

34-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35+
# This is the only python test wired into CI (.github/workflows/ci.yml,
36+
# tests-build-and-lag job) that speaks the socket protocol. It has been
37+
# ported off the retired v1 line-protocol client (tests/cmux.py) onto the
38+
# v2 JSON-RPC client (tests_v2/cmux.py) so nothing CI-gated depends on v1.
39+
_TESTS_V2_DIR = os.path.normpath(
40+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tests_v2")
41+
)
42+
sys.path.insert(0, _TESTS_V2_DIR)
3543
from cmux import cmux, cmuxError
3644

3745
NEW_WORKSPACES = int(os.environ.get("PROGRAMA_LAG_NEW_WORKSPACES", "20"))
@@ -65,10 +73,21 @@ class LatencyStats:
6573

6674

6775
class RawSocketClient:
76+
"""Minimal v2 JSON-RPC client used only for the latency-critical simulate_shortcut loop.
77+
78+
Speaks the same one-JSON-object-per-line framing as tests_v2/cmux.py
79+
(``{"id": N, "method": ..., "params": {...}}`` in, ``{"id": N, "ok": ...}``
80+
out), but skips the full client's id-resolution helpers so the measured
81+
round trip is just: write one line, read one line. Kept separate from the
82+
full v2 cmux client (rather than reusing it) so the timing-loop shape and
83+
per-call overhead stay identical to the pre-port v1 measurement.
84+
"""
85+
6886
def __init__(self, socket_path: str):
6987
self.socket_path = socket_path
7088
self.sock: Optional[socket.socket] = None
7189
self.recv_buffer = ""
90+
self._next_id = 1
7291

7392
def connect(self) -> None:
7493
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@@ -90,31 +109,50 @@ def __enter__(self) -> RawSocketClient:
90109
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
91110
self.close()
92111

93-
def command(self, command: str, timeout_s: float = 2.0) -> str:
112+
def call(self, method: str, params: Optional[dict] = None, timeout_s: float = 2.0) -> dict:
94113
if self.sock is None:
95114
raise cmuxError("Raw socket client not connected")
96115

97-
self.sock.sendall((command + "\n").encode("utf-8"))
116+
req_id = self._next_id
117+
self._next_id += 1
118+
payload = {"id": req_id, "method": method, "params": params or {}}
119+
self.sock.sendall((json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8"))
98120
deadline = time.time() + timeout_s
99121

100122
while True:
101123
if "\n" in self.recv_buffer:
102124
line, self.recv_buffer = self.recv_buffer.split("\n", 1)
103-
return line
125+
break
104126

105127
remaining = deadline - time.time()
106128
if remaining <= 0:
107-
raise cmuxError(f"Timed out waiting for response to: {command}")
129+
raise cmuxError(f"Timed out waiting for response to: {method}")
108130

109131
ready, _, _ = select.select([self.sock], [], [], remaining)
110132
if not ready:
111-
raise cmuxError(f"Timed out waiting for response to: {command}")
133+
raise cmuxError(f"Timed out waiting for response to: {method}")
112134

113135
chunk = self.sock.recv(8192)
114136
if not chunk:
115137
raise cmuxError("Socket closed while waiting for response")
116138
self.recv_buffer += chunk.decode("utf-8", errors="replace")
117139

140+
try:
141+
resp = json.loads(line)
142+
except json.JSONDecodeError as e:
143+
raise cmuxError(f"Invalid JSON response: {e}: {line[:200]}")
144+
145+
if not isinstance(resp, dict) or resp.get("id") != req_id:
146+
raise cmuxError(f"Mismatched or invalid response to {method}: {line[:200]}")
147+
148+
if resp.get("ok") is True:
149+
return resp.get("result") or {}
150+
151+
err = resp.get("error") or {}
152+
code = err.get("code") or "error"
153+
msg = err.get("message") or "Unknown error"
154+
raise cmuxError(f"{code}: {msg}")
155+
118156

119157
def wait_for(predicate: Callable[[], bool], timeout_s: float, step_s: float = 0.05) -> None:
120158
start = time.time()
@@ -224,8 +262,11 @@ def keep_only_first_workspace(client: cmux) -> str:
224262
# The app may still be settling when this runs right after socket connect
225263
# (workspaces closing/restoring from the previous session), so a workspace
226264
# listed one moment can be gone by the time we select/close it, surfacing
227-
# as "ERROR: Tab not found". Re-snapshot and retry instead of failing the
228-
# whole run on that startup race.
265+
# as a v2 "not_found: Workspace not found" error (the v1 client's
266+
# equivalent was the literal string "ERROR: Tab not found"). Both contain
267+
# "not found" case-insensitively, so the retry match below covers either
268+
# client. Re-snapshot and retry instead of failing the whole run on that
269+
# startup race.
229270
deadline = time.time() + 10.0
230271
last_error: Optional[cmuxError] = None
231272
while True:
@@ -286,9 +327,17 @@ def focused_terminal_panel(client: cmux) -> str:
286327
return focused[1]
287328

288329

330+
def send_line(client: cmux, text: str) -> None:
331+
# tests_v2/cmux.py mirrors most of the v1 client's convenience API but
332+
# doesn't ship a send_line helper, so this is a small local shim rather
333+
# than a change to the shared v2 client. client.send() already unescapes
334+
# backslash-n into a real newline, matching v1's send_line semantics.
335+
client.send(text + "\\n")
336+
337+
289338
def seed_history(client: cmux, lines: int) -> None:
290339
for i in range(lines):
291-
client.send_line(f"echo cmux-lag-seed-{i}")
340+
send_line(client, f"echo cmux-lag-seed-{i}")
292341

293342

294343
def run_shortcut_latency_burst(
@@ -301,16 +350,12 @@ def run_shortcut_latency_burst(
301350
with RawSocketClient(socket_path) as raw:
302351
# Warm up the command path and responder chain.
303352
for _ in range(5):
304-
response = raw.command(f"simulate_shortcut {combo}")
305-
if not response.startswith("OK"):
306-
raise cmuxError(response)
353+
raw.call("debug.shortcut.simulate", {"combo": combo})
307354

308355
for _ in range(count):
309356
start = time.perf_counter()
310-
response = raw.command(f"simulate_shortcut {combo}")
357+
raw.call("debug.shortcut.simulate", {"combo": combo})
311358
elapsed_ms = (time.perf_counter() - start) * 1000.0
312-
if not response.startswith("OK"):
313-
raise cmuxError(response)
314359
latencies_ms.append(elapsed_ms)
315360
if delay_s > 0:
316361
time.sleep(delay_s)

0 commit comments

Comments
 (0)