Filter DA1 responses from replay buffer on WebSocket connect
The replay buffer can contain DA1/DA2 terminal attribute responses (e.g., \x1b[?1;10;0c) that were captured before filtering was added to the session classes. These responses appear as visible text like '1;10;0c' when sent to the client on reconnect. This adds an additional filter pass when sending the replay buffer, ensuring no DA1 responses reach the client regardless of when they were captured.
This commit is contained in:
@@ -31,12 +31,12 @@ DEFAULT_SCREEN_HEIGHT = 45
|
|||||||
# Pattern to filter out terminal device attribute responses that cause display issues
|
# Pattern to filter out terminal device attribute responses that cause display issues
|
||||||
# These are responses to queries that shouldn't be displayed as text.
|
# These are responses to queries that shouldn't be displayed as text.
|
||||||
# Matches complete DA1/DA2 responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c
|
# Matches complete DA1/DA2 responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c
|
||||||
DA_RESPONSE_PATTERN = re.compile(rb'\x1b\[\?[\d;]+c')
|
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c")
|
||||||
|
|
||||||
# Pattern to detect partial DA responses at end of data (incomplete escape sequence)
|
# Pattern to detect partial DA responses at end of data (incomplete escape sequence)
|
||||||
# Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, \x1b[?1;10, etc.
|
# Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, \x1b[?1;10, etc.
|
||||||
# These need to be held back until more data arrives to see if they complete
|
# These need to be held back until more data arrives to see if they complete
|
||||||
DA_PARTIAL_PATTERN = re.compile(rb'\x1b(?:\[(?:\?[\d;]*)?)?$')
|
DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:\?[\d;]*)?)?$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -160,7 +160,9 @@ class DockerExecSession(Session):
|
|||||||
"Tty": True,
|
"Tty": True,
|
||||||
"Cmd": self.exec_spec.command,
|
"Cmd": self.exec_spec.command,
|
||||||
}
|
}
|
||||||
response = self._request_json("POST", f"/containers/{self.exec_spec.container}/exec", payload)
|
response = self._request_json(
|
||||||
|
"POST", f"/containers/{self.exec_spec.container}/exec", payload
|
||||||
|
)
|
||||||
exec_id = response.get("Id")
|
exec_id = response.get("Id")
|
||||||
if not isinstance(exec_id, str) or not exec_id:
|
if not isinstance(exec_id, str) or not exec_id:
|
||||||
raise RuntimeError("Docker API did not return exec ID")
|
raise RuntimeError("Docker API did not return exec ID")
|
||||||
@@ -316,7 +318,7 @@ class DockerExecSession(Session):
|
|||||||
self._escape_buffer = b""
|
self._escape_buffer = b""
|
||||||
|
|
||||||
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
|
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
|
||||||
data = DA_RESPONSE_PATTERN.sub(b'', data)
|
data = DA_RESPONSE_PATTERN.sub(b"", data)
|
||||||
if not data:
|
if not data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import contextlib
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -26,6 +27,11 @@ from .session_manager import SessionManager
|
|||||||
from .svg_exporter import render_terminal_svg
|
from .svg_exporter import render_terminal_svg
|
||||||
from .types import Meta, RouteKey, SessionID
|
from .types import Meta, RouteKey, SessionID
|
||||||
|
|
||||||
|
# Pattern to filter terminal device attribute responses (DA1/DA2) from replay buffer.
|
||||||
|
# These responses can appear as visible text like "1;10;0c" if split across reads.
|
||||||
|
# See docker_exec_session.py and terminal_session.py for main filtering.
|
||||||
|
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c")
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
||||||
@@ -324,10 +330,20 @@ class LocalServer:
|
|||||||
self._docker_stats = DockerStatsCollector(compose_project=self._compose_project)
|
self._docker_stats = DockerStatsCollector(compose_project=self._compose_project)
|
||||||
if self._docker_stats.available:
|
if self._docker_stats.available:
|
||||||
# Pass service names (not slugs) for Docker matching
|
# Pass service names (not slugs) for Docker matching
|
||||||
service_names = [app.name for app in (self._landing_apps if self._compose_mode else self.session_manager.apps)]
|
service_names = [
|
||||||
|
app.name
|
||||||
|
for app in (
|
||||||
|
self._landing_apps if self._compose_mode else self.session_manager.apps
|
||||||
|
)
|
||||||
|
]
|
||||||
self._docker_stats.start(service_names)
|
self._docker_stats.start(service_names)
|
||||||
# Create slug->name mapping for lookups
|
# Create slug->name mapping for lookups
|
||||||
self._slug_to_service = {app.slug: app.name for app in (self._landing_apps if self._compose_mode else self.session_manager.apps)}
|
self._slug_to_service = {
|
||||||
|
app.slug: app.name
|
||||||
|
for app in (
|
||||||
|
self._landing_apps if self._compose_mode else self.session_manager.apps
|
||||||
|
)
|
||||||
|
}
|
||||||
log.info("Slug to service mapping: %s", self._slug_to_service)
|
log.info("Slug to service mapping: %s", self._slug_to_service)
|
||||||
stack.push_async_callback(self._docker_stats.stop)
|
stack.push_async_callback(self._docker_stats.stop)
|
||||||
|
|
||||||
@@ -457,6 +473,10 @@ class LocalServer:
|
|||||||
|
|
||||||
if session_created and session is not None and hasattr(session, "get_replay_buffer"):
|
if session_created and session is not None and hasattr(session, "get_replay_buffer"):
|
||||||
replay = await session.get_replay_buffer()
|
replay = await session.get_replay_buffer()
|
||||||
|
if replay:
|
||||||
|
# Filter out any DA1/DA2 responses that may have been captured
|
||||||
|
# in the replay buffer before filtering was added to session classes
|
||||||
|
replay = DA_RESPONSE_PATTERN.sub(b"", replay)
|
||||||
if replay:
|
if replay:
|
||||||
await ws.send_bytes(replay)
|
await ws.send_bytes(replay)
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ DEFAULT_SCREEN_HEIGHT = 45
|
|||||||
# Pattern to filter out terminal device attribute responses that cause display issues
|
# Pattern to filter out terminal device attribute responses that cause display issues
|
||||||
# These are responses to DA1/DA2 queries that shouldn't be displayed as text
|
# These are responses to DA1/DA2 queries that shouldn't be displayed as text
|
||||||
# Matches complete responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c
|
# Matches complete responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c
|
||||||
DA_RESPONSE_PATTERN = re.compile(rb'\x1b\[\?[\d;]+c')
|
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c")
|
||||||
|
|
||||||
# Pattern to detect partial DA responses at end of data (incomplete escape sequence)
|
# Pattern to detect partial DA responses at end of data (incomplete escape sequence)
|
||||||
# Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, etc.
|
# Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, etc.
|
||||||
DA_PARTIAL_PATTERN = re.compile(rb'\x1b(?:\[(?:\?[\d;]*)?)?$')
|
DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:\?[\d;]*)?)?$")
|
||||||
|
|
||||||
|
|
||||||
class TerminalSession(Session):
|
class TerminalSession(Session):
|
||||||
@@ -331,7 +331,7 @@ class TerminalSession(Session):
|
|||||||
self._escape_buffer = b""
|
self._escape_buffer = b""
|
||||||
|
|
||||||
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
|
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
|
||||||
data = DA_RESPONSE_PATTERN.sub(b'', data)
|
data = DA_RESPONSE_PATTERN.sub(b"", data)
|
||||||
if not data:
|
if not data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class TestCLI:
|
|||||||
|
|
||||||
def test_cli_runs_default_shell(self, monkeypatch):
|
def test_cli_runs_default_shell(self, monkeypatch):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
calls: dict[str, object] = {}
|
calls: dict[str, object] = {}
|
||||||
|
|
||||||
class FakeServer:
|
class FakeServer:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from webterm import cli
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
|
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
|
||||||
|
|
||||||
manifest = tmp_path / "landing.yaml"
|
manifest = tmp_path / "landing.yaml"
|
||||||
manifest.write_text(
|
manifest.write_text(
|
||||||
"""
|
"""
|
||||||
@@ -40,7 +39,6 @@ def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path):
|
def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path):
|
||||||
|
|
||||||
manifest = tmp_path / "compose.yaml"
|
manifest = tmp_path / "compose.yaml"
|
||||||
manifest.write_text(
|
manifest.write_text(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from webterm.types import RouteKey, SessionID
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
async def _make_client(server: LocalServer) -> TestClient:
|
async def _make_client(server: LocalServer) -> TestClient:
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.add_routes(server._build_routes())
|
app.add_routes(server._build_routes())
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ class TestSessionManager:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert isinstance(result, DockerExecSession)
|
assert isinstance(result, DockerExecSession)
|
||||||
|
|
||||||
|
|
||||||
class TestSessionManagerRoutes:
|
class TestSessionManagerRoutes:
|
||||||
"""Tests for SessionManager route handling."""
|
"""Tests for SessionManager route handling."""
|
||||||
|
|
||||||
|
|||||||
@@ -168,17 +168,11 @@ class TestTerminalSession:
|
|||||||
session = TerminalSession(mock_poller, "test-session", command)
|
session = TerminalSession(mock_poller, "test-session", command)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)) as mock_fork,
|
||||||
"webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)
|
|
||||||
) as mock_fork,
|
|
||||||
patch("webterm.terminal_session.version", return_value="0.0.0"),
|
patch("webterm.terminal_session.version", return_value="0.0.0"),
|
||||||
patch("webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
|
patch("webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
|
||||||
patch(
|
patch("webterm.terminal_session.os.execvp", side_effect=OSError()) as mock_execvp,
|
||||||
"webterm.terminal_session.os.execvp", side_effect=OSError()
|
patch("webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
|
||||||
) as mock_execvp,
|
|
||||||
patch(
|
|
||||||
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
|
||||||
) as mock_exit,
|
|
||||||
pytest.raises(SystemExit),
|
pytest.raises(SystemExit),
|
||||||
):
|
):
|
||||||
await session.open()
|
await session.open()
|
||||||
@@ -209,9 +203,7 @@ class TestTerminalSession:
|
|||||||
with (
|
with (
|
||||||
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
|
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
|
||||||
patch("webterm.terminal_session.shlex.split", side_effect=ValueError("bad")),
|
patch("webterm.terminal_session.shlex.split", side_effect=ValueError("bad")),
|
||||||
patch(
|
patch("webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
|
||||||
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
|
||||||
) as mock_exit,
|
|
||||||
pytest.raises(SystemExit),
|
pytest.raises(SystemExit),
|
||||||
):
|
):
|
||||||
await session.open()
|
await session.open()
|
||||||
|
|||||||
Reference in New Issue
Block a user