Skip to content

Commit f29df94

Browse files
committed
refactor: use pyte for exec ANSI stripping and add ConnectionClosed handling
Replace regex-based _strip_ansi with pyte single-row screen for marker detection. pyte interprets all VT100/VT220 sequences including OSC and truecolor escapes that the regex could miss.
1 parent ba0e9d5 commit f29df94

2 files changed

Lines changed: 109 additions & 47 deletions

File tree

‎centml/cli/shell.py‎

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import asyncio
44
import json
5-
import re
65
import shutil
76
import signal
87
import sys
@@ -337,8 +336,6 @@ def _send_resize():
337336
sys.stdout.buffer.flush()
338337

339338

340-
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\].*?\x07|\x1b\[.*?\x1b\\")
341-
342339
_BEGIN_MARKER = "__CENTML_BEGIN_5f3a__"
343340
_END_MARKER = "__CENTML_END_5f3a__"
344341

@@ -348,9 +345,17 @@ def _send_resize():
348345
_PRINTF_END = r"\137\137CENTML_END_5f3a\137\137"
349346

350347

351-
def _strip_ansi(text):
352-
"""Remove ANSI escape sequences from text."""
353-
return _ANSI_ESCAPE_RE.sub("", text)
348+
def _pyte_extract_text(line_stream, line_screen, text):
349+
"""Feed text through a single-row pyte screen and return visible characters.
350+
351+
More robust than regex ANSI stripping: pyte interprets all VT100/VT220
352+
sequences including OSC, cursor repositioning, and truecolor escapes.
353+
"""
354+
line_screen.reset()
355+
line_stream.feed(text)
356+
return "".join(
357+
line_screen.buffer[0][col].data for col in range(line_screen.columns)
358+
).rstrip()
354359

355360

356361
async def _exec_session(ws_url, token, command):
@@ -360,6 +365,9 @@ async def _exec_session(ws_url, token, command):
360365
Suppresses shell echo and uses markers to capture only command output.
361366
"""
362367
cols, rows = shutil.get_terminal_size(fallback=(80, 24))
368+
# Single-row screen for interpreting escape sequences in marker detection.
369+
line_screen = pyte.Screen(cols, 1)
370+
line_stream = pyte.Stream(line_screen)
363371
headers = {"Authorization": f"Bearer {token}"}
364372

365373
async with websockets.connect(
@@ -386,35 +394,40 @@ async def _exec_session(ws_url, token, command):
386394
buffer = ""
387395
is_capturing = False
388396
is_done = False
389-
async for raw_msg in ws:
390-
msg = json.loads(raw_msg)
391-
if msg.get("data"):
392-
buffer += msg["data"]
393-
while "\n" in buffer:
394-
line, buffer = buffer.split("\n", 1)
395-
clean = _strip_ansi(line).rstrip("\r")
396-
if _BEGIN_MARKER in clean:
397-
is_capturing = True
398-
continue
399-
if _END_MARKER in clean:
400-
parts = clean.split(_END_MARKER + ":")
401-
if len(parts) > 1:
402-
try:
403-
exit_code = int(parts[1].strip())
404-
except ValueError:
405-
pass
406-
is_done = True
407-
break
408-
if is_capturing:
409-
sys.stdout.write(line + "\n")
410-
sys.stdout.flush()
411-
elif msg.get("error"):
412-
sys.stderr.write(f"Error: {msg['error']}\n")
413-
return 1
414-
if is_done or "Code" in msg:
415-
if "Code" in msg:
416-
exit_code = msg["Code"]
417-
break
397+
try:
398+
async for raw_msg in ws:
399+
msg = json.loads(raw_msg)
400+
if msg.get("data"):
401+
buffer += msg["data"]
402+
while "\n" in buffer:
403+
line, buffer = buffer.split("\n", 1)
404+
clean = _pyte_extract_text(
405+
line_stream, line_screen, line.rstrip("\r")
406+
)
407+
if _BEGIN_MARKER in clean:
408+
is_capturing = True
409+
continue
410+
if _END_MARKER in clean:
411+
parts = clean.split(_END_MARKER + ":")
412+
if len(parts) > 1:
413+
try:
414+
exit_code = int(parts[1].strip())
415+
except ValueError:
416+
pass
417+
is_done = True
418+
break
419+
if is_capturing:
420+
sys.stdout.write(line + "\n")
421+
sys.stdout.flush()
422+
elif msg.get("error"):
423+
sys.stderr.write(f"Error: {msg['error']}\n")
424+
return 1
425+
if is_done or "Code" in msg:
426+
if "Code" in msg:
427+
exit_code = msg["Code"]
428+
break
429+
except websockets.ConnectionClosed:
430+
pass
418431
return exit_code
419432

420433

‎tests/test_shell.py‎

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,6 @@ def _make_status_response(revisions):
5252
# ===========================================================================
5353

5454

55-
class TestStripAnsi:
56-
def test_strips_csi_sequences(self):
57-
from centml.cli.shell import _strip_ansi
58-
59-
assert _strip_ansi("\x1b[?2004htext\x1b[0m") == "text"
60-
61-
def test_preserves_plain_text(self):
62-
from centml.cli.shell import _strip_ansi
63-
64-
assert _strip_ansi("hello world") == "hello world"
65-
66-
6755
class TestBuildWsUrl:
6856
def test_https_to_wss(self):
6957
from centml.cli.shell import _build_ws_url
@@ -339,6 +327,67 @@ def test_filters_noise_before_marker(self):
339327
assert "real output" in output
340328
assert "prompt$" not in output
341329

330+
def test_connection_closed_returns_zero(self):
331+
"""Graceful exit when server closes connection without Code message."""
332+
from centml.cli.shell import _exec_session
333+
334+
import websockets as _ws_lib
335+
336+
ws = AsyncMock()
337+
338+
async def _raise_closed():
339+
yield json.dumps({"data": "partial\n"})
340+
raise _ws_lib.ConnectionClosed(None, None)
341+
342+
ws.__aiter__ = MagicMock(return_value=_raise_closed())
343+
344+
with patch("centml.cli.shell.websockets") as mock_ws_mod:
345+
mock_ws_mod.connect = MagicMock(
346+
return_value=AsyncMock(
347+
__aenter__=AsyncMock(return_value=ws),
348+
__aexit__=AsyncMock(return_value=False),
349+
)
350+
)
351+
mock_ws_mod.ConnectionClosed = _ws_lib.ConnectionClosed
352+
353+
exit_code = asyncio.run(
354+
_exec_session("wss://test/ws", "fake-token", "exit")
355+
)
356+
357+
assert exit_code == 0
358+
359+
def test_handles_ansi_around_markers(self):
360+
"""Markers wrapped in ANSI codes are still detected via pyte."""
361+
from centml.cli.shell import _exec_session, _BEGIN_MARKER, _END_MARKER
362+
363+
ws = AsyncMock()
364+
# Markers surrounded by ANSI color codes.
365+
data = f"\x1b[32m{_BEGIN_MARKER}\x1b[0m\noutput\n\x1b[32m{_END_MARKER}:0\x1b[0m\n"
366+
messages = [json.dumps({"data": data}), json.dumps({"Code": 0})]
367+
ws.__aiter__ = MagicMock(return_value=_async_iter_from_list(messages))
368+
369+
captured = []
370+
with patch("centml.cli.shell.websockets") as mock_ws_mod, patch(
371+
"centml.cli.shell.sys"
372+
) as mock_sys:
373+
mock_ws_mod.connect = MagicMock(
374+
return_value=AsyncMock(
375+
__aenter__=AsyncMock(return_value=ws),
376+
__aexit__=AsyncMock(return_value=False),
377+
)
378+
)
379+
mock_sys.stdout.write = lambda s: captured.append(s)
380+
mock_sys.stdout.flush = MagicMock()
381+
mock_sys.stderr.write = MagicMock()
382+
383+
exit_code = asyncio.run(
384+
_exec_session("wss://test/ws", "fake-token", "echo test")
385+
)
386+
387+
assert exit_code == 0
388+
output = "".join(captured)
389+
assert "output" in output
390+
342391

343392
# ===========================================================================
344393
# _interactive_session -- terminal restore

0 commit comments

Comments
 (0)