1919
2020from __future__ import annotations
2121
22+ import json
2223import os
2324import select
2425import socket
3132from pathlib import Path
3233from 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 )
3543from cmux import cmux , cmuxError
3644
3745NEW_WORKSPACES = int (os .environ .get ("PROGRAMA_LAG_NEW_WORKSPACES" , "20" ))
@@ -65,10 +73,21 @@ class LatencyStats:
6573
6674
6775class 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
119157def 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+
289338def 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
294343def 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