diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d2a41f603..f3351cd4d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,6 +83,13 @@ jobs: COV_CORE_SOURCE: . COV_CORE_CONFIG: .coveragerc COV_CORE_DATAFILE: .coverage.eager + - name: Test with pytest (control engine) + continue-on-error: ${{ matrix.tmux-version == 'master' }} + run: | + sudo apt install libevent-2.1-7 + export PATH=$HOME/tmux-builds/tmux-${{ matrix.tmux-version }}/bin:$PATH + tmux -V + uv run py.test --engine=control -n auto --verbose - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGES b/CHANGES index 9213addd0..8e9e4fcf7 100644 --- a/CHANGES +++ b/CHANGES @@ -34,6 +34,61 @@ $ uvx --from 'libtmux' --prerelease allow python _Upcoming changes will be written here._ +### Features + +#### Control mode engine (experimental) (#605) +- Control-mode–first engine stack with structured `CommandResult`, notification parsing + (%layout-change, unlinked window add/close/rename, client session change/detach, paste buffer), + and stats, while keeping the public `cmd` API returning `tmux_cmd`. +- Internal control session is automatically filtered from `Server.sessions` / + `Server.has_session()`; advanced users can inspect all sessions via + `Server._sessions_all()`. +- `ControlModeEngine` accepts `internal_session_name` (default `libtmux_control_mode`) + and `control_session` for advanced connection strategies. + +Example: + +```python +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux.server import Server + +engine = ControlModeEngine(command_timeout=5) +server = Server(engine=engine) +session = server.new_session(session_name="ctrl") +for notif in engine.iter_notifications(timeout=0.1): + print(notif.kind, notif.data) +``` + +#### Convenience APIs (#605) +- `Server.connect()` returns an existing session or creates a new detached one, + working with both subprocess and control-mode engines. + +#### Diagnostics and resilience (#605) +- Bounded notification queue with drop counting; exposed via engine stats. +- Expanded exceptions: `ControlModeTimeout`, `ControlModeProtocolError`, + `ControlModeConnectionError`, `SubprocessTimeout`; documented retry/timeout behaviour. +- Control-mode capture-pane trims trailing whitespace-only lines to mirror subprocess + semantics; first capture after send-keys briefly retries to avoid prompt races. + +#### Testing utilities (#605) +- Control sandbox fixture provides a hermetic control-mode tmux server (isolated HOME, + TMUX_TMPDIR, unique socket); handy for integration-style tests. + Example: + + ```python + @pytest.mark.engines(["control"]) + def test_control_sandbox(control_sandbox): + with control_sandbox as server: + out = server.cmd("display-message", "-p", "hi") + assert out.stdout == ["hi"] + ``` + +### Compatibility + +- Control mode internal session filtering is engine-driven; callers no longer need + to manually exclude `libtmux_control_mode`. APIs stay unchanged; additional metadata + is attached for advanced users. (#605) + ## libtmux 0.50.1 (2025-12-06) ### Documentation (#612) diff --git a/conftest.py b/conftest.py index ada5aae3f..d8be63db3 100644 --- a/conftest.py +++ b/conftest.py @@ -10,12 +10,17 @@ from __future__ import annotations +import contextlib +import pathlib import shutil +import subprocess import typing as t +import uuid import pytest from _pytest.doctest import DoctestItem +from libtmux._internal.engines.control_protocol import CommandContext, ControlProtocol from libtmux.pane import Pane from libtmux.pytest_plugin import USING_ZSH from libtmux.server import Server @@ -73,3 +78,141 @@ def setup_session( """Session-level test configuration for pytest.""" if USING_ZSH: request.getfixturevalue("zshrc") + + +# --------------------------------------------------------------------------- +# Control-mode sandbox helper +# --------------------------------------------------------------------------- + + +@pytest.fixture +@contextlib.contextmanager +def control_sandbox( + monkeypatch: pytest.MonkeyPatch, + tmp_path_factory: pytest.TempPathFactory, +) -> t.Iterator[Server]: + """Provide an isolated control-mode server for a test. + + - Creates a unique tmux socket name per invocation + - Isolates HOME and TMUX_TMPDIR under a per-test temp directory + - Clears TMUX env var to avoid inheriting user sessions + - Uses ControlModeEngine; on exit, kills the server best-effort + """ + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + base = tmp_path_factory.mktemp("ctrl_sandbox") + home = base / "home" + tmux_tmpdir = base / "tmux" + home.mkdir() + tmux_tmpdir.mkdir() + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("TMUX_TMPDIR", str(tmux_tmpdir)) + monkeypatch.delenv("TMUX", raising=False) + + from libtmux._internal.engines.control_mode import ControlModeEngine + + server = Server(socket_name=socket_name, engine=ControlModeEngine()) + + try: + yield server + finally: + with contextlib.suppress(Exception): + server.kill() + + +@pytest.fixture +def control_client_logs( + control_sandbox: t.ContextManager[Server], + tmp_path_factory: pytest.TempPathFactory, +) -> t.Iterator[tuple[subprocess.Popen[str], ControlProtocol]]: + """Spawn a raw tmux -C client against the sandbox and log stdout/stderr.""" + base = tmp_path_factory.mktemp("ctrl_logs") + stdout_path = base / "control_stdout.log" + stderr_path = base / "control_stderr.log" + + with control_sandbox as server: + cmd = [ + "tmux", + "-L", + server.socket_name or "", + "-C", + "attach-session", + "-t", + "ctrltest", + ] + # Ensure ctrltest exists + server.cmd("new-session", "-d", "-s", "ctrltest") + stdout_path.open("w+", buffering=1) + stderr_f = stderr_path.open("w+", buffering=1) + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=stderr_f, + text=True, + bufsize=1, + ) + proto = ControlProtocol() + # tmux -C will emit a %begin/%end pair for this initial attach-session; + # queue a matching context so the parser has a pending command. + proto.register_command(CommandContext(argv=list(cmd))) + try: + yield proc, proto + finally: + with contextlib.suppress(Exception): + if proc.stdin: + proc.stdin.write("kill-session -t ctrltest\n") + proc.stdin.flush() + proc.terminate() + proc.wait(timeout=2) + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add CLI options for selecting tmux engine.""" + parser.addoption( + "--engine", + action="store", + default="subprocess", + choices=["subprocess", "control"], + help="Select tmux engine for fixtures (default: subprocess).", + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Register custom markers.""" + config.addinivalue_line( + "markers", + ( + "engines(names): run the test once for each engine in 'names' " + "(e.g. ['control', 'subprocess'])." + ), + ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Parametrize engine_name when requested by tests.""" + if "engine_name" in metafunc.fixturenames: + marker = metafunc.definition.get_closest_marker("engines") + if marker: + params = list(marker.args[0]) + else: + params = [metafunc.config.getoption("--engine")] + metafunc.parametrize("engine_name", params, indirect=True) + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + """Filter out doctests when running with control engine. + + Remove doctests from collection to avoid pytest's _use_item_location + bug: DoctestItem.reportinfo() returns None lineno for fixture doctests, + which triggers assertion failure in _pytest/reports.py:420 when skipped. + """ + engine_opt = config.getoption("--engine", default="subprocess") + if engine_opt != "control": + return + + # Filter out DoctestItems - can't use skip markers due to pytest bug + items[:] = [item for item in items if not isinstance(item, DoctestItem)] diff --git a/docs/api/index.md b/docs/api/index.md index 49c720a5c..807465f62 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4,6 +4,12 @@ # API Reference +:::{note} +Looking for the new control-mode engine? See {ref}`control-mode` for an +experimental, protocol-focused entry point that still preserves the public +``tmux_cmd`` return type. +::: + ```{toctree} properties diff --git a/docs/pytest-plugin/index.md b/docs/pytest-plugin/index.md index 82d55dd2f..3bcd0ef44 100644 --- a/docs/pytest-plugin/index.md +++ b/docs/pytest-plugin/index.md @@ -137,6 +137,53 @@ def set_home( monkeypatch.setenv("HOME", str(user_path)) ``` +## Selecting tmux engines (experimental) + +Fixtures can run against different execution engines. By default the +`subprocess` engine is used. You can choose control mode globally: + +```console +$ pytest --engine=control +``` + +Or per-test via the `engines` marker (uses parametrization) and the `engine_name` +fixture: + +```python +import pytest + +@pytest.mark.engines(["subprocess", "control"]) +def test_my_flow(server, engine_name): + # server uses the selected engine, engine_name reflects the current one + assert engine_name in {"subprocess", "control"} + assert server.is_alive() +``` + +`TestServer` also respects the selected engine. Control mode is experimental and +its APIs may change between releases. + +### Control sandbox fixture (experimental) + +Use ``control_sandbox`` when you need a hermetic control-mode server for a test: + +```python +import typing as t +import pytest +from libtmux.server import Server + +@pytest.mark.engines(["control"]) +def test_control_sandbox(control_sandbox: t.ContextManager[Server]): + with control_sandbox as server: + session = server.new_session(session_name="sandbox", attach=False) + out = server.cmd("display-message", "-p", "hi") + assert out.stdout == ["hi"] +``` + +The fixture: +- Spins up a unique socket name and isolates ``HOME`` / ``TMUX_TMPDIR`` +- Clears inherited ``TMUX`` so it never attaches to the user's server +- Uses ``ControlModeEngine`` and cleans up the server on exit + ## Fixtures ```{eval-rst} diff --git a/docs/quickstart.md b/docs/quickstart.md index 39b11aa70..7e6a4f22b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -157,6 +157,23 @@ in your server object. `libtmux.Server(socket_name='mysocket')` is equivalent to `$ tmux -L mysocket`. ::: +### Optional: Control mode (experimental) + +Control mode keeps a persistent tmux client open and streams +notifications. Enable it by injecting a control-mode engine: + +```python +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux.server import Server + +engine = ControlModeEngine() +server = Server(engine=engine) +session = server.new_session(session_name="ctrl") +print(session.name) +``` + +See {ref}`control-mode` for details, caveats, and notification handling. + `server` is now a living object bound to the tmux server's Sessions, Windows and Panes. @@ -263,6 +280,41 @@ Session($1 foo) to give us a `session` object to play with. +## Connect to or create a session + +A simpler approach is to use {meth}`Server.connect()`, which returns an existing +session or creates it if it doesn't exist: + +```python +>>> session = server.connect('my_project') +>>> session.name +'my_project' + +>>> # Calling again returns the same session +>>> session2 = server.connect('my_project') +>>> session2.session_id == session.session_id +True +``` + +This is particularly useful for: + +- Development workflows where you want to reuse existing sessions +- Scripts that should create a session on first run, then reattach +- Working with both subprocess and control-mode engines transparently + +Compare with the traditional approach: + +```python +>>> # Traditional: check then create +>>> if server.has_session('my_project'): +... session = server.sessions.get(session_name='my_project') +... else: +... session = server.new_session('my_project') + +>>> # Simpler with connect() +>>> session = server.connect('my_project') +``` + ## Playing with our tmux session We now have access to `session` from above with all of the methods diff --git a/docs/topics/control_mode.md b/docs/topics/control_mode.md new file mode 100644 index 000000000..db7be2eee --- /dev/null +++ b/docs/topics/control_mode.md @@ -0,0 +1,215 @@ +--- +orphan: true +--- + +(control-mode)= + +# Control Mode Engine (experimental) + +:::{warning} +This is an **experimental API**. Names and behavior may change between releases. +Use with caution and pin your libtmux version if you depend on it. +::: + +libtmux can drive tmux through a persistent Control Mode client. This keeps a +single connection open, pipelines commands, and surfaces tmux notifications in a +typed stream. + +## Why use Control Mode? + +- Lower overhead than spawning a tmux process per command +- Access to live notifications: layout changes, window/link events, client + detach/attach, paste buffer updates, and more +- Structured command results with timing/flag metadata + +## Using ControlModeEngine + +```python +from __future__ import annotations + +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux.server import Server + +engine = ControlModeEngine(command_timeout=5) +server = Server(engine=engine) + +session = server.new_session(session_name="ctrl-demo") +print(session.name) + +# Consume notifications (non-blocking example) +for notif in engine.iter_notifications(timeout=0.1): + print(notif.kind, notif.data) +``` + +:::{note} +Control mode creates an internal session for connection management (default name: +`libtmux_control_mode`). This session is automatically filtered from +`Server.sessions` and `Server.has_session()` to maintain engine transparency. +::: + +## Session management with Control Mode + +The {meth}`Server.connect()` method works seamlessly with control mode: + +```python +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux.server import Server + +engine = ControlModeEngine() +server = Server(engine=engine) + +# Reuses session if it exists, creates if it doesn't +session = server.connect("dev-session") +print(session.name) + +# Calling again returns the same session +session2 = server.connect("dev-session") +assert session2.session_id == session.session_id +``` + +This works transparently with both control mode and subprocess engines, making it +easy to switch between them without changing your code. + +## Advanced Configuration + +### Custom Internal Session Name + +For testing or advanced scenarios, you can customize the internal session name: + +```python +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux.server import Server + +engine = ControlModeEngine(internal_session_name="my_control_session") +server = Server(engine=engine) + +# Internal session is still filtered +user_session = server.new_session("my_app") +len(server.sessions) # 1 (only my_app visible) + +# But exists internally +len(server._sessions_all()) # 2 (my_app + my_control_session) +``` + +### Attach to Existing Session + +For expert use cases, control mode can attach to an existing session instead of +creating an internal one: + +```python +# Create a session first +server.new_session("shared") + +# Control mode attaches to it for its connection +engine = ControlModeEngine(control_session="shared") +server = Server(engine=engine) + +# The shared session is visible (not filtered) +len(server.sessions) # 1 (shared session) +``` + +:::{warning} +Attaching to active user sessions will generate notification traffic from pane +output and layout changes. This increases protocol parsing overhead and may impact +performance. Use only when you need control mode notifications for a specific session. +::: + +## Parsing notifications directly + +The protocol parser can be used without tmux to understand the wire format. + +```python +>>> from libtmux._internal.engines.control_protocol import ControlProtocol +>>> proto = ControlProtocol() +>>> proto.feed_line("%layout-change @1 abcd efgh 0") +>>> notif = proto.get_notification() +>>> notif.kind.name +'WINDOW_LAYOUT_CHANGED' +>>> notif.data['window_layout'] +'abcd' +``` + +## Fallback engine + +If control mode is unavailable, ``SubprocessEngine`` matches the same +``Engine`` interface but runs one tmux process per command: + +```python +from libtmux._internal.engines.subprocess_engine import SubprocessEngine +from libtmux.server import Server + +server = Server(engine=SubprocessEngine()) +print(server.list_sessions()) # legacy behavior +``` + +## Key behaviors + +- Commands still return ``tmux_cmd`` objects for compatibility, but extra + metadata (``exit_status``, ``cmd_id``, ``tmux_time``) is attached. +- Notifications are queued; drops are counted when consumers fall behind. +- Timeouts raise ``ControlModeTimeout`` and restart the control client. + +## Runtime client flags + +- Use :meth:`ControlModeEngine.set_client_flags` to toggle tmux's control + client flags at runtime (``no-output``, ``pause-after``, ``wait-exit``, and + general client flags like ``read-only`` and ``ignore-size``). Passing + ``False`` clears a flag using tmux's ``!flag`` syntax; ``pause_after=0`` also + clears pause-after. +- Per-pane flow control can be adjusted with + :meth:`ControlModeEngine.set_pane_flow`, which wraps ``refresh-client -A``. + Call ``set_pane_flow(pane_id, state="continue")`` to resume a paused pane. + +## Subscriptions + +- :meth:`ControlModeEngine.subscribe` wraps ``refresh-client -B`` to add or + remove control-mode subscriptions. Provide a ``name``, optional ``what`` + scope (``%1``, ``@2``, ``%*``, ``@*``, or empty for the attached session), + and a ``format`` string. Pass ``format=None`` to remove a subscription by + name. + +## Errors, timeouts, and retries + +- ``ControlModeTimeout`` — command block did not finish before + ``command_timeout``. The engine closes and restarts the control client. +- ``ControlModeConnectionError`` — control socket died (EOF/broken pipe). The + engine restarts and replays the pending command once. +- ``ControlModeProtocolError`` — malformed ``%begin/%end/%error`` framing; the + client is marked dead and must be restarted. +- ``SubprocessTimeout`` — subprocess fallback exceeded its timeout. + +## Notifications and backpressure + +- Notifications are enqueued in a bounded queue (default 4096). When the queue + fills, additional notifications are dropped and the drop counter is reported + via :class:`~libtmux._internal.engines.base.EngineStats`. +- Consume notifications via :meth:`ControlModeEngine.iter_notifications` to + avoid drops; use a small timeout (e.g., 0.1s) for non-blocking loops. + +## Environment propagation requirements + +- libtmux requires tmux **3.2 or newer**; ``-e KEY=VAL`` on ``new-session``, + ``new-window``, and ``split-window`` is supported natively. +- Environment tests and examples may wait briefly after ``send-keys`` so the + shell prompt/output reaches the pane before capture. + +## Capture-pane normalization + +- Control mode trims trailing *whitespace-only* lines from ``capture-pane`` to + match subprocess behaviour. If you request explicit ranges (``-S/-E``) or use + ``-N/-J``, output is left untouched. +- In control mode, the first capture after ``send-keys`` can race the shell; + libtmux retries briefly to ensure the prompt/output is visible. + +## Control sandbox (tests/diagnostics) + +The pytest fixture ``control_sandbox`` provides an isolated control-mode tmux +server with a unique socket, HOME/TMUX_TMPDIR isolation, and automatic cleanup. +It is used by the regression suite and can be reused in custom tests when you +need a hermetic control-mode client. + +## Testing control mode + +- Run `uv run pytest --engine=control` to execute the suite against the + control-mode engine. CI runs both subprocess and control engines across + supported tmux versions. diff --git a/docs/topics/index.md b/docs/topics/index.md index f22e7f81b..8b26eca4e 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -15,4 +15,5 @@ workspace_setup automation_patterns context_managers options_and_hooks +control_mode ``` diff --git a/src/libtmux/_internal/engines/base.py b/src/libtmux/_internal/engines/base.py new file mode 100644 index 000000000..8cb22ae5a --- /dev/null +++ b/src/libtmux/_internal/engines/base.py @@ -0,0 +1,278 @@ +"""Engine abstractions and shared types for libtmux.""" + +from __future__ import annotations + +import dataclasses +import enum +import typing as t +from abc import ABC, abstractmethod + +from libtmux.common import tmux_cmd + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@dataclasses.dataclass(frozen=True) +class ServerContext: + """Immutable server connection context. + + Passed to :meth:`Engine.bind` so engines can execute commands + without requiring `server_args` on every hook call. + """ + + socket_name: str | None = None + socket_path: str | None = None + config_file: str | None = None + + def to_args(self) -> tuple[str, ...]: + """Convert context to tmux server argument tuple. + + Returns args in concatenated form (e.g., ``-Lsocket_name``) to match + the format used by ``Server._build_server_args()``. + """ + args: list[str] = [] + if self.socket_name: + args.append(f"-L{self.socket_name}") + if self.socket_path: + args.append(f"-S{self.socket_path}") + if self.config_file: + args.append(f"-f{self.config_file}") + return tuple(args) + + +class ExitStatus(enum.Enum): + """Exit status returned by tmux control mode commands.""" + + OK = 0 + ERROR = 1 + + +@dataclasses.dataclass +class CommandResult: + """Canonical result shape produced by engines. + + This is the internal representation used by engines. Public-facing APIs + still return :class:`libtmux.common.tmux_cmd` for compatibility; see + :func:`command_result_to_tmux_cmd`. + """ + + argv: list[str] + stdout: list[str] + stderr: list[str] + exit_status: ExitStatus + cmd_id: int | None = None + start_time: float | None = None + end_time: float | None = None + tmux_time: int | None = None + flags: int | None = None + + @property + def returncode(self) -> int: + """Return a POSIX-style return code matching tmux expectations.""" + return 0 if self.exit_status is ExitStatus.OK else 1 + + +class NotificationKind(enum.Enum): + """High-level categories for tmux control-mode notifications.""" + + PANE_OUTPUT = enum.auto() + PANE_EXTENDED_OUTPUT = enum.auto() + PANE_MODE_CHANGED = enum.auto() + WINDOW_LAYOUT_CHANGED = enum.auto() + WINDOW_ADD = enum.auto() + WINDOW_CLOSE = enum.auto() + UNLINKED_WINDOW_ADD = enum.auto() + UNLINKED_WINDOW_CLOSE = enum.auto() + UNLINKED_WINDOW_RENAMED = enum.auto() + WINDOW_RENAMED = enum.auto() + WINDOW_PANE_CHANGED = enum.auto() + SESSION_CHANGED = enum.auto() + CLIENT_SESSION_CHANGED = enum.auto() + CLIENT_DETACHED = enum.auto() + SESSION_RENAMED = enum.auto() + SESSIONS_CHANGED = enum.auto() + SESSION_WINDOW_CHANGED = enum.auto() + PASTE_BUFFER_CHANGED = enum.auto() + PASTE_BUFFER_DELETED = enum.auto() + PAUSE = enum.auto() + CONTINUE = enum.auto() + SUBSCRIPTION_CHANGED = enum.auto() + EXIT = enum.auto() + MESSAGE = enum.auto() + CONFIG_ERROR = enum.auto() + RAW = enum.auto() + + +@dataclasses.dataclass +class Notification: + """Parsed notification emitted by tmux control mode.""" + + kind: NotificationKind + when: float + raw: str + data: dict[str, t.Any] + + +@dataclasses.dataclass +class EngineStats: + """Light-weight diagnostics about engine state.""" + + in_flight: int + notif_queue_depth: int + dropped_notifications: int + restarts: int + last_error: str | None + last_activity: float | None + + +def command_result_to_tmux_cmd(result: CommandResult) -> tmux_cmd: + """Adapt :class:`CommandResult` into the legacy ``tmux_cmd`` wrapper.""" + proc = tmux_cmd( + cmd=result.argv, + stdout=result.stdout, + stderr=result.stderr, + returncode=result.returncode, + ) + # Preserve extra metadata for consumers that know about it. + proc.exit_status = result.exit_status # type: ignore[attr-defined] + proc.cmd_id = result.cmd_id # type: ignore[attr-defined] + proc.tmux_time = result.tmux_time # type: ignore[attr-defined] + proc.flags = result.flags # type: ignore[attr-defined] + proc.start_time = result.start_time # type: ignore[attr-defined] + proc.end_time = result.end_time # type: ignore[attr-defined] + return proc + + +class Engine(ABC): + """Abstract base class for tmux execution engines. + + Engines produce :class:`CommandResult` internally but surface ``tmux_cmd`` + to the existing libtmux public surface. Subclasses should implement + :meth:`run_result` and rely on the base :meth:`run` adapter unless they have + a strong reason to override both. + """ + + _server_context: ServerContext | None = None + + def bind(self, context: ServerContext) -> None: + """Bind engine to server context. + + Called by :class:`Server.__init__` to provide connection details. + Engines can use this to run commands without requiring ``server_args`` + on every hook call. + + Parameters + ---------- + context : ServerContext + Immutable server connection context. + """ + self._server_context = context + + def run( + self, + cmd: str, + cmd_args: t.Sequence[str | int] | None = None, + server_args: t.Sequence[str | int] | None = None, + timeout: float | None = None, + ) -> tmux_cmd: + """Run a tmux command and return a ``tmux_cmd`` wrapper.""" + return command_result_to_tmux_cmd( + self.run_result( + cmd=cmd, + cmd_args=cmd_args, + server_args=server_args, + timeout=timeout, + ), + ) + + @abstractmethod + def run_result( + self, + cmd: str, + cmd_args: t.Sequence[str | int] | None = None, + server_args: t.Sequence[str | int] | None = None, + timeout: float | None = None, + ) -> CommandResult: + """Run a tmux command and return a :class:`CommandResult`.""" + + def iter_notifications( + self, + *, + timeout: float | None = None, + ) -> t.Iterator[Notification]: # pragma: no cover - default noop + """Yield control-mode notifications if supported by the engine.""" + if False: # keeps the function a generator for typing + yield timeout + return + + # Optional hooks --------------------------------------------------- + def probe_server_alive(self) -> bool | None: + """Probe if tmux server is alive without starting the engine. + + Uses the bound :attr:`_server_context` for connection details. + + Returns + ------- + bool | None + True if server is alive, False if dead, None to use default check. + Return None to fall back to running ``list-sessions`` via the engine. + + Notes + ----- + Override in engines that shouldn't start on probe (e.g., ControlModeEngine). + """ + return None + + def can_switch_client(self) -> bool: + """Check if switch-client is meaningful for this engine. + + Uses the bound :attr:`_server_context` for connection details. + + Returns + ------- + bool + True if there is at least one client that can be switched. + Default implementation returns True (assumes switching is allowed). + """ + return True + + @property + def internal_session_names(self) -> set[str]: + """Names of sessions reserved for engine internals.""" + return set() + + def filter_sessions( + self, + sessions: list[Session], + ) -> list[Session]: + """Filter sessions, hiding any internal/management sessions. + + Uses the bound :attr:`_server_context` for connection details. + + Parameters + ---------- + sessions : list[Session] + All sessions from the server. + + Returns + ------- + list[Session] + Sessions after filtering out any engine-internal ones. + """ + return sessions + + def get_stats(self) -> EngineStats: # pragma: no cover - default noop + """Return engine diagnostic stats.""" + return EngineStats( + in_flight=0, + notif_queue_depth=0, + dropped_notifications=0, + restarts=0, + last_error=None, + last_activity=None, + ) + + def close(self) -> None: # pragma: no cover - default noop + """Clean up any engine resources.""" + return None diff --git a/src/libtmux/_internal/engines/control_mode.py b/src/libtmux/_internal/engines/control_mode.py new file mode 100644 index 000000000..2f9e5c8cb --- /dev/null +++ b/src/libtmux/_internal/engines/control_mode.py @@ -0,0 +1,709 @@ +"""Control Mode engine for libtmux.""" + +from __future__ import annotations + +import logging +import shlex +import shutil +import subprocess +import threading +import typing as t +import uuid + +from libtmux import exc +from libtmux._internal.engines.base import ( + CommandResult, + Engine, + EngineStats, + ExitStatus, + Notification, +) +from libtmux._internal.engines.control_protocol import ( + CommandContext, + ControlProtocol, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + +logger = logging.getLogger(__name__) + + +class _ControlProcess(t.Protocol): + """Protocol for control-mode process handle (real or test fake).""" + + stdin: t.TextIO | None + stdout: t.Iterable[str] | None + stderr: t.Iterable[str] | None + pid: int | None + + def terminate(self) -> None: ... + + def kill(self) -> None: ... + + def wait(self, timeout: float | None = None) -> t.Any: ... + + def poll(self) -> int | None: ... + + +class _ProcessFactory(t.Protocol): + """Protocol for constructing a control-mode process.""" + + def __call__( + self, + cmd: list[str], + *, + stdin: t.Any, + stdout: t.Any, + stderr: t.Any, + text: bool, + bufsize: int, + errors: str, + ) -> _ControlProcess: ... + + +class ControlModeEngine(Engine): + """Engine that runs tmux commands via a persistent Control Mode process. + + By default, creates an internal session for connection management. + This session is hidden from user-facing APIs like Server.sessions. + + Error Handling + -------------- + Connection errors (BrokenPipeError, EOF) raise + :class:`~libtmux.exc.ControlModeConnectionError` and are automatically + retried up to ``max_retries`` times (default: 1). + + Timeouts raise :class:`~libtmux.exc.ControlModeTimeout` and are NOT retried. + If operations frequently timeout, increase ``command_timeout``. + + Notifications + ------------- + A bounded notification queue (default 4096) records out-of-band events with + drop counting when consumers fall behind. Use :meth:`iter_notifications` to + consume events or :meth:`drain_notifications` to wait for idle state. + """ + + def __init__( + self, + command_timeout: float | None = 10.0, + notification_queue_size: int = 4096, + internal_session_name: str | None = None, + control_session: str | None = None, + process_factory: _ProcessFactory | None = None, + max_retries: int = 1, + start_threads: bool = True, + ) -> None: + """Initialize control mode engine. + + Parameters + ---------- + command_timeout : float, optional + Timeout for tmux commands in seconds. Default: 10.0 + notification_queue_size : int + Size of notification queue. Default: 4096 + internal_session_name : str, optional + Custom name for internal control session. + Default: Auto-generated unique name (libtmux_ctrl_XXXXXXXX) + + The internal session is used for connection management and is + automatically filtered from user-facing APIs. A unique name is + generated automatically to avoid collisions with user sessions. + control_session : str, optional + Attach to existing session instead of creating internal one. + When set, control mode attaches to this session for its connection. + + .. warning:: + Attaching to user sessions can cause notification spam from + pane output. Use for advanced scenarios only. + process_factory : _ProcessFactory, optional + Test hook to override how the tmux control-mode process is created. + When provided, it receives the argv list and must return an object + compatible with ``subprocess.Popen`` (stdin/stdout/stderr streams). + max_retries : int, optional + Number of times to retry a command after a BrokenPipeError while + writing to the control-mode process. Default: 1. + start_threads : bool, optional + Internal/testing hook to skip spawning reader/stderr threads when + using a fake process that feeds the protocol directly. Default: True. + """ + self.process: _ControlProcess | None = None + self._lock = threading.Lock() + self._server_args: tuple[str | int, ...] | None = None + self.command_timeout = command_timeout + self.tmux_bin: str | None = None + self._reader_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + self._notification_queue_size = notification_queue_size + self._protocol = ControlProtocol( + notification_queue_size=notification_queue_size, + ) + self._restarts = 0 + self._internal_session_name = ( + internal_session_name or f"libtmux_ctrl_{uuid.uuid4().hex[:8]}" + ) + self._control_session = control_session + self._process_factory = process_factory + self._max_retries = max(0, max_retries) + self._start_threads = start_threads + + # Lifecycle --------------------------------------------------------- + def close(self) -> None: + """Terminate the tmux control mode process and clean up threads. + + Terminates the subprocess and waits for reader/stderr threads to + finish. Non-daemon threads ensure clean shutdown without races. + """ + proc = self.process + if proc is None: + return + + try: + proc.terminate() + proc.wait(timeout=1) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + finally: + self.process = None + self._server_args = None + self._protocol.mark_dead("engine closed") + + # Join threads to ensure clean shutdown (non-daemon threads). + # Skip join if called from within the thread itself (e.g., during GC). + current = threading.current_thread() + if ( + self._reader_thread is not None + and self._reader_thread.is_alive() + and self._reader_thread is not current + ): + self._reader_thread.join(timeout=2) + if ( + self._stderr_thread is not None + and self._stderr_thread.is_alive() + and self._stderr_thread is not current + ): + self._stderr_thread.join(timeout=2) + + def __del__(self) -> None: # pragma: no cover - best effort cleanup + """Ensure subprocess is terminated on GC.""" + self.close() + + # Engine API -------------------------------------------------------- + def run_result( + self, + cmd: str, + cmd_args: t.Sequence[str | int] | None = None, + server_args: t.Sequence[str | int] | None = None, + timeout: float | None = None, + ) -> CommandResult: + """Run a tmux command and return a :class:`CommandResult`.""" + incoming_server_args = tuple(server_args or ()) + effective_timeout = timeout if timeout is not None else self.command_timeout + attempts = 0 + + while True: + attempts += 1 + with self._lock: + self._ensure_process(incoming_server_args) + assert self.process is not None + full_argv: list[str] = [ + self.tmux_bin or "tmux", + *[str(x) for x in incoming_server_args], + cmd, + ] + if cmd_args: + full_argv.extend(str(a) for a in cmd_args) + + ctx = CommandContext(argv=full_argv) + self._protocol.register_command(ctx) + + command_line = shlex.join([cmd, *(str(a) for a in cmd_args or [])]) + try: + self._write_line(command_line, server_args=incoming_server_args) + except exc.ControlModeConnectionError: + if attempts > self._max_retries: + raise + # retry the full cycle with a fresh process/context + continue + + # Wait outside the lock so multiple callers can run concurrently + if not ctx.wait(timeout=effective_timeout): + self.close() + self._restarts += 1 + msg = "tmux control mode command timed out" + raise exc.ControlModeTimeout(msg) + + if ctx.error is not None: + # Treat EOF after kill-* as success: tmux closes control socket. + if isinstance(ctx.error, exc.ControlModeConnectionError) and ( + cmd in {"kill-server", "kill-session"} + ): + ctx.exit_status = ExitStatus.OK + ctx.error = None + else: + raise ctx.error + + if ctx.exit_status is None: + ctx.exit_status = ExitStatus.OK + + return self._protocol.build_result(ctx) + + def iter_notifications( + self, + *, + timeout: float | None = None, + ) -> t.Iterator[Notification]: + """Yield control-mode notifications until the stream ends.""" + while True: + notif = self._protocol.get_notification(timeout=timeout) + if notif is None: + return + if notif.kind.name == "EXIT": + return + yield notif + + def drain_notifications( + self, + *, + idle_duration: float = 0.1, + timeout: float = 8.0, + ) -> list[Notification]: + """Drain notifications until the queue is idle. + + This helper is useful when you need to wait for notification activity + to settle after an operation that may generate multiple notifications + (e.g., attach-session in control_session mode). + + Parameters + ---------- + idle_duration : float, optional + Consider the queue idle after this many seconds of silence. + Default: 0.1 (100ms) + timeout : float, optional + Maximum time to wait for idle state. Default: 8.0 + Matches RETRY_TIMEOUT_SECONDS from libtmux.test.retry. + + Returns + ------- + list[Notification] + All notifications received before idle state. + + Raises + ------ + TimeoutError + If timeout is reached before idle state. + """ + import time + + collected: list[Notification] = [] + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + notif = self._protocol.get_notification(timeout=idle_duration) + if notif is None: + # Queue was idle for idle_duration - we're done + return collected + if notif.kind.name == "EXIT": + return collected + collected.append(notif) + + msg = f"Notification queue did not become idle within {timeout}s" + raise TimeoutError(msg) + + def set_client_flags( + self, + *, + no_output: bool | None = None, + pause_after: int | None = None, + wait_exit: bool | None = None, + ignore_size: bool | None = None, + active_pane: bool | None = None, + read_only: bool | None = None, + no_detach_on_destroy: bool | None = None, + ) -> None: + """Set control client flags via ``refresh-client -f``. + + These are runtime flags on the connected client. Boolean flags are + toggled using tmux's ``!flag`` negation semantics and are left unchanged + when passed ``None``. + + Parameters + ---------- + no_output : bool, optional + Filter ``%output`` notifications. ``False`` clears the flag. + pause_after : int, optional + Pause after N seconds of buffering; 0 clears the flag. + wait_exit : bool, optional + Keep control connection alive until tmux exit is reported. + ignore_size : bool, optional + Ignore size updates from the client. + active_pane : bool, optional + Mark client as active-pane. + read_only : bool, optional + Prevent modifications from this client. + no_detach_on_destroy : bool, optional + Mirror tmux's ``no-detach-on-destroy`` client flag. + + Examples + -------- + >>> engine.set_client_flags(no_output=True) # doctest: +SKIP + >>> engine.set_client_flags(pause_after=5) # doctest: +SKIP + >>> engine.set_client_flags(wait_exit=True) # doctest: +SKIP + >>> engine.set_client_flags(no_output=False) # doctest: +SKIP + """ + + def _bool_flag(name: str, value: bool | None) -> str | None: + if value is None: + return None + return name if value else f"!{name}" + + flags: list[str] = [] + + maybe_flag = _bool_flag("no-output", no_output) + if maybe_flag: + flags.append(maybe_flag) + + if pause_after is not None: + if pause_after < 0: + msg = "pause_after must be >= 0" + raise ValueError(msg) + if pause_after == 0: + flags.append("!pause-after") + else: + flags.append(f"pause-after={pause_after}") + + maybe_flag = _bool_flag("wait-exit", wait_exit) + if maybe_flag: + flags.append(maybe_flag) + + for name, value in ( + ("ignore-size", ignore_size), + ("active-pane", active_pane), + ("read-only", read_only), + ("no-detach-on-destroy", no_detach_on_destroy), + ): + maybe_flag = _bool_flag(name, value) + if maybe_flag: + flags.append(maybe_flag) + + if flags: + server_args = self._server_context.to_args() if self._server_context else () + self.run( + "refresh-client", + cmd_args=("-f", ",".join(flags)), + server_args=server_args, + ) + + def set_pane_flow(self, pane_id: str | int, state: str = "continue") -> None: + """Set per-pane flow control for the control client. + + This maps to ``refresh-client -A pane:state`` where ``state`` is one of + ``on``, ``off``, ``pause``, or ``continue``. The default resumes a + paused pane. + """ + if state not in {"on", "off", "pause", "continue"}: + msg = "state must be one of on|off|pause|continue" + raise ValueError(msg) + + server_args = self._server_context.to_args() if self._server_context else () + self.run( + "refresh-client", + cmd_args=("-A", f"{pane_id}:{state}"), + server_args=server_args, + ) + + def subscribe( + self, + name: str, + *, + what: str | None = None, + fmt: str | None = None, + ) -> None: + """Manage control-mode subscriptions. + + Subscriptions emit ``%subscription-changed`` notifications when the + provided format changes. Passing ``format=None`` removes the + subscription by name. + """ + server_args = self._server_context.to_args() if self._server_context else () + + if fmt is None: + # Remove subscription + self.run( + "refresh-client", + cmd_args=("-B", name), + server_args=server_args, + ) + return + + target = what or "" + payload = f"{name}:{target}:{fmt}" + self.run( + "refresh-client", + cmd_args=("-B", payload), + server_args=server_args, + ) + + def get_stats(self) -> EngineStats: + """Return diagnostic statistics for the engine.""" + return self._protocol.get_stats(restarts=self._restarts) + + @property + def internal_session_names(self) -> set[str]: + """Session names reserved for the engine's control connection.""" + if self._control_session: + return set() + return {self._internal_session_name} + + def probe_server_alive(self) -> bool | None: + """Check if tmux server is alive without starting control mode. + + Performs a direct subprocess check to avoid bootstrapping the control + mode connection just to probe server liveness. + + Returns + ------- + bool + True if server is alive (list-sessions returns 0), False otherwise. + """ + tmux_bin = shutil.which("tmux") + if tmux_bin is None: + return False + + server_args = self._server_context.to_args() if self._server_context else () + result = subprocess.run( + [tmux_bin, *server_args, "list-sessions"], + check=False, + capture_output=True, + ) + return result.returncode == 0 + + def filter_sessions( + self, + sessions: list[Session], + ) -> list[Session]: + """Hide sessions that are only attached via the control-mode client.""" + if self.process is None or self.process.pid is None: + return sessions + + ctrl_pid = str(self.process.pid) + server_args = self._server_context.to_args() if self._server_context else () + + proc = self.run( + "list-clients", + cmd_args=( + "-F", + "#{client_pid} #{client_flags} #{session_name}", + ), + server_args=server_args, + ) + pid_map: dict[str, list[tuple[str, str]]] = {} + for line in proc.stdout: + parts = line.split() + if len(parts) >= 3: + pid, flags, sess_name = parts[0], parts[1], parts[2] + pid_map.setdefault(sess_name, []).append((pid, flags)) + + filtered: list[Session] = [] + for sess_obj in sessions: + sess_name = sess_obj.session_name or "" + + # Never expose the internal control session we create to hold the + # control client when control_session is unset. + if not self._control_session and sess_name == self._internal_session_name: + continue + + clients = pid_map.get(sess_name, []) + non_control_clients = [ + (pid, flags) + for pid, flags in clients + if "control-mode" not in flags and pid != ctrl_pid + ] + + if non_control_clients: + filtered.append(sess_obj) + + return filtered + + def can_switch_client(self) -> bool: + """Return True if there is at least one non-control client attached.""" + if self.process is None or self.process.pid is None: + return False + + ctrl_pid = str(self.process.pid) + server_args = self._server_context.to_args() if self._server_context else () + + proc = self.run( + "list-clients", + cmd_args=("-F", "#{client_pid} #{client_flags}"), + server_args=server_args, + ) + for line in proc.stdout: + parts = line.split() + if len(parts) >= 2: + pid, flags = parts[0], parts[1] + if "control-mode" not in flags and pid != ctrl_pid: + return True + + return False + + # Internals --------------------------------------------------------- + def _ensure_process(self, server_args: tuple[str | int, ...]) -> None: + if self.process is None: + self._start_process(server_args) + return + + if server_args != self._server_args: + logger.warning( + ( + "Server args changed; restarting Control Mode process. " + "Old: %s, New: %s" + ), + self._server_args, + server_args, + ) + self.close() + self._start_process(server_args) + + def _start_process(self, server_args: tuple[str | int, ...]) -> None: + tmux_bin = shutil.which("tmux") + if not tmux_bin: + raise exc.TmuxCommandNotFound + + self.tmux_bin = tmux_bin + self._server_args = server_args + self._protocol = ControlProtocol( + notification_queue_size=self._notification_queue_size, + ) + + # Build command based on configuration + if self._control_session: + # Fail fast if attach target is missing before starting control mode. + has_session_cmd = [ + tmux_bin, + *[str(a) for a in server_args], + "has-session", + "-t", + self._control_session, + ] + probe = subprocess.run( + has_session_cmd, + capture_output=True, + text=True, + ) + if probe.returncode != 0: + msg = f"control_session not found: {self._control_session}" + raise exc.ControlModeConnectionError(msg) + + # Attach to existing session (advanced mode) + cmd = [ + tmux_bin, + *[str(a) for a in server_args], + "-C", + "attach-session", + "-t", + self._control_session, + ] + bootstrap_argv = [ + tmux_bin, + *[str(a) for a in server_args], + "attach-session", + "-t", + self._control_session, + ] + else: + # Create or attach to internal session (default) + cmd = [ + tmux_bin, + *[str(a) for a in server_args], + "-C", + "new-session", + "-A", + "-s", + self._internal_session_name, + ] + bootstrap_argv = [ + tmux_bin, + *[str(a) for a in server_args], + "new-session", + "-A", + "-s", + self._internal_session_name, + ] + + logger.debug("Starting Control Mode process: %s", cmd) + popen_factory: _ProcessFactory = ( + self._process_factory or subprocess.Popen # type: ignore[assignment] + ) + self.process = popen_factory( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=0, + errors="backslashreplace", + ) + + # The initial command (new-session or attach-session) emits an output + # block; register a context so the protocol can consume it. + bootstrap_ctx = CommandContext(argv=bootstrap_argv) + self._protocol.register_command(bootstrap_ctx) + + # Start IO threads after registration to avoid early protocol errors. + # Non-daemon threads ensure clean shutdown via join() in close(). + if self._start_threads: + self._reader_thread = threading.Thread( + target=self._reader, + args=(self.process,), + daemon=False, + ) + self._reader_thread.start() + + self._stderr_thread = threading.Thread( + target=self._drain_stderr, + args=(self.process,), + daemon=False, + ) + self._stderr_thread.start() + + if not bootstrap_ctx.wait(timeout=self.command_timeout): + self.close() + msg = "Control Mode bootstrap command timed out" + raise exc.ControlModeTimeout(msg) + + def _write_line( + self, + command_line: str, + *, + server_args: tuple[str | int, ...], + ) -> None: + assert self.process is not None + assert self.process.stdin is not None + + try: + self.process.stdin.write(command_line + "\n") + self.process.stdin.flush() + except BrokenPipeError: + logger.exception("Control Mode process died, restarting...") + self.close() + self._restarts += 1 + msg = "control mode process unavailable" + raise exc.ControlModeConnectionError(msg) from None + + def _reader(self, process: _ControlProcess) -> None: + assert process.stdout is not None + try: + for raw in process.stdout: + self._protocol.feed_line(raw.rstrip("\n")) + except Exception: # pragma: no cover - defensive + logger.exception("Control Mode reader thread crashed") + finally: + self._protocol.mark_dead("EOF from tmux") + + def _drain_stderr(self, process: _ControlProcess) -> None: + if process.stderr is None: + return + for err_line in process.stderr: + logger.debug("Control Mode stderr: %s", err_line.rstrip("\n")) diff --git a/src/libtmux/_internal/engines/control_protocol.py b/src/libtmux/_internal/engines/control_protocol.py new file mode 100644 index 000000000..a132ee71e --- /dev/null +++ b/src/libtmux/_internal/engines/control_protocol.py @@ -0,0 +1,388 @@ +"""Control mode protocol parsing and bookkeeping.""" + +from __future__ import annotations + +import collections +import dataclasses +import enum +import logging +import queue +import threading +import time +import typing as t + +from libtmux import exc +from libtmux._internal.engines.base import ( + CommandResult, + EngineStats, + ExitStatus, + Notification, + NotificationKind, +) + +logger = logging.getLogger(__name__) + + +def _trim_lines(lines: list[str]) -> list[str]: + """Remove trailing empty or whitespace-only lines to mirror subprocess behaviour.""" + trimmed = list(lines) + while trimmed and trimmed[-1].strip() == "": + trimmed.pop() + return trimmed + + +@dataclasses.dataclass +class CommandContext: + """Tracks state for a single in-flight control-mode command.""" + + argv: list[str] + cmd_id: int | None = None + tmux_time: int | None = None + flags: int | None = None + stdout: list[str] = dataclasses.field(default_factory=list) + stderr: list[str] = dataclasses.field(default_factory=list) + exit_status: ExitStatus | None = None + start_time: float | None = None + end_time: float | None = None + done: threading.Event = dataclasses.field(default_factory=threading.Event) + error: BaseException | None = None + + def signal_done(self) -> None: + """Mark the context as complete.""" + self.done.set() + + def wait(self, timeout: float | None) -> bool: + """Wait for completion; returns False on timeout.""" + return self.done.wait(timeout=timeout) + + +class ParserState(enum.Enum): + """Minimal state machine for control-mode parsing.""" + + IDLE = enum.auto() + IN_COMMAND = enum.auto() + SKIPPING = enum.auto() # Skipping unexpected %begin/%end block + DEAD = enum.auto() + + +def _parse_notification(line: str, parts: list[str]) -> Notification: + """Map raw notification lines into structured :class:`Notification`. + + The mapping is intentionally conservative; unknown tags fall back to RAW. + """ + tag = parts[0] + now = time.monotonic() + data: dict[str, t.Any] = {} + kind = NotificationKind.RAW + + if tag == "%output" and len(parts) >= 3: + kind = NotificationKind.PANE_OUTPUT + data = {"pane_id": parts[1], "payload": " ".join(parts[2:])} + elif tag == "%extended-output" and len(parts) >= 3: + # Format: %extended-output %{pane_id} {age_ms} : {payload} + # The colon separates metadata from payload + colon_idx = line.find(" : ") + if colon_idx != -1: + kind = NotificationKind.PANE_EXTENDED_OUTPUT + data = { + "pane_id": parts[1], + "behind_ms": parts[2], + "payload": line[colon_idx + 3 :], + } + elif tag == "%pane-mode-changed" and len(parts) >= 2: + kind = NotificationKind.PANE_MODE_CHANGED + data = {"pane_id": parts[1], "mode": parts[2:]} + elif tag == "%layout-change" and len(parts) >= 5: + kind = NotificationKind.WINDOW_LAYOUT_CHANGED + data = { + "window_id": parts[1], + "window_layout": parts[2], + "window_visible_layout": parts[3], + "window_raw_flags": parts[4], + } + elif tag == "%window-add" and len(parts) >= 2: + kind = NotificationKind.WINDOW_ADD + data = {"window_id": parts[1], "rest": parts[2:]} + elif tag == "%unlinked-window-add" and len(parts) >= 2: + kind = NotificationKind.UNLINKED_WINDOW_ADD + data = {"window_id": parts[1], "rest": parts[2:]} + elif tag == "%window-close" and len(parts) >= 2: + kind = NotificationKind.WINDOW_CLOSE + data = {"window_id": parts[1]} + elif tag == "%unlinked-window-close" and len(parts) >= 2: + kind = NotificationKind.UNLINKED_WINDOW_CLOSE + data = {"window_id": parts[1]} + elif tag == "%window-renamed" and len(parts) >= 3: + kind = NotificationKind.WINDOW_RENAMED + data = {"window_id": parts[1], "name": " ".join(parts[2:])} + elif tag == "%unlinked-window-renamed" and len(parts) >= 3: + kind = NotificationKind.UNLINKED_WINDOW_RENAMED + data = {"window_id": parts[1], "name": " ".join(parts[2:])} + elif tag == "%window-pane-changed" and len(parts) >= 3: + kind = NotificationKind.WINDOW_PANE_CHANGED + data = {"window_id": parts[1], "pane_id": parts[2]} + elif tag == "%session-changed" and len(parts) >= 3: + # Format: %session-changed ${session_id} {session_name} + kind = NotificationKind.SESSION_CHANGED + data = {"session_id": parts[1], "session_name": " ".join(parts[2:])} + elif tag == "%client-session-changed" and len(parts) >= 4: + kind = NotificationKind.CLIENT_SESSION_CHANGED + data = { + "client_name": parts[1], + "session_id": parts[2], + "session_name": parts[3], + } + elif tag == "%client-detached" and len(parts) >= 2: + kind = NotificationKind.CLIENT_DETACHED + data = {"client_name": parts[1]} + elif tag == "%session-renamed" and len(parts) >= 3: + kind = NotificationKind.SESSION_RENAMED + data = {"session_id": parts[1], "session_name": " ".join(parts[2:])} + elif tag == "%sessions-changed": + kind = NotificationKind.SESSIONS_CHANGED + elif tag == "%session-window-changed" and len(parts) >= 3: + kind = NotificationKind.SESSION_WINDOW_CHANGED + data = {"session_id": parts[1], "window_id": parts[2]} + elif tag == "%paste-buffer-changed" and len(parts) >= 2: + kind = NotificationKind.PASTE_BUFFER_CHANGED + data = {"name": parts[1]} + elif tag == "%paste-buffer-deleted" and len(parts) >= 2: + kind = NotificationKind.PASTE_BUFFER_DELETED + data = {"name": parts[1]} + elif tag == "%pause" and len(parts) >= 2: + kind = NotificationKind.PAUSE + data = {"pane_id": parts[1]} + elif tag == "%continue" and len(parts) >= 2: + kind = NotificationKind.CONTINUE + data = {"pane_id": parts[1]} + elif tag == "%subscription-changed" and len(parts) >= 6: + # Format: %subscription-changed {name} ${session_id} @{window_id} {index} + # %{pane_id} : {value} + # Fields can be "-" for "not applicable". Colon separates metadata from value. + colon_idx = line.find(" : ") + if colon_idx != -1: + kind = NotificationKind.SUBSCRIPTION_CHANGED + data = { + "name": parts[1], + "session_id": parts[2] if parts[2] != "-" else None, + "window_id": parts[3] if parts[3] != "-" else None, + "window_index": parts[4] if parts[4] != "-" else None, + "pane_id": parts[5] if parts[5] != "-" else None, + "value": line[colon_idx + 3 :], + } + elif tag == "%exit": + # Format: %exit or %exit {reason} + kind = NotificationKind.EXIT + data = {"reason": " ".join(parts[1:]) if len(parts) > 1 else None} + elif tag == "%message" and len(parts) >= 2: + kind = NotificationKind.MESSAGE + data = {"text": " ".join(parts[1:])} + elif tag == "%config-error" and len(parts) >= 2: + kind = NotificationKind.CONFIG_ERROR + data = {"error": " ".join(parts[1:])} + + return Notification(kind=kind, when=now, raw=line, data=data) + + +class ControlProtocol: + """Parse the tmux control-mode stream into commands and notifications. + + Maintains a FIFO queue of pending :class:`CommandContext` objects that are + matched to `%begin/%end/%error` blocks, plus a bounded notification queue + (default 4096) for out-of-band events. When the queue is full, additional + notifications are dropped and counted so callers can detect backpressure. + """ + + def __init__(self, *, notification_queue_size: int = 4096) -> None: + self.state = ParserState.IDLE + self._pending: collections.deque[CommandContext] = collections.deque() + self._current: CommandContext | None = None + self._notif_queue: queue.Queue[Notification] = queue.Queue( + maxsize=notification_queue_size, + ) + self._dropped_notifications = 0 + self._last_error: str | None = None + self._last_activity: float | None = None + + # Command lifecycle ------------------------------------------------- + def register_command(self, ctx: CommandContext) -> None: + """Queue a command context awaiting %begin/%end.""" + self._pending.append(ctx) + + def feed_line(self, line: str) -> None: + """Feed a raw line from tmux into the parser.""" + self._last_activity = time.monotonic() + if self.state is ParserState.DEAD: + return + + if line.startswith("%"): + self._handle_percent_line(line) + else: + self._handle_plain_line(line) + + def _handle_percent_line(self, line: str) -> None: + parts = line.split() + tag = parts[0] + + if tag == "%begin": + self._on_begin(parts) + elif tag in ("%end", "%error"): + self._on_end_or_error(tag, parts) + elif self.state is ParserState.IN_COMMAND and self._current: + # Inside a command block, lines starting with % that aren't + # control messages (begin/end/error) are likely tmux identifiers + # (pane IDs like %3, window IDs like @2, etc.) from -P flag output + # Treat them as stdout content, not notifications + self._current.stdout.append(line) + else: + self._on_notification(line, parts) + + def _handle_plain_line(self, line: str) -> None: + if self.state is ParserState.IN_COMMAND and self._current: + self._current.stdout.append(line) + elif self.state is ParserState.SKIPPING: + # Ignore output from skipped blocks (hook command output) + pass + else: + logger.debug("Unexpected plain line outside command: %r", line) + + def _on_begin(self, parts: list[str]) -> None: + if self.state is ParserState.SKIPPING: + # Nested %begin while skipping - ignore + logger.debug("Nested %%begin while skipping: %s", parts) + return + if self.state is not ParserState.IDLE: + self._protocol_error("nested %begin") + return + + try: + tmux_time = int(parts[1]) + cmd_id = int(parts[2]) + flags = int(parts[3]) if len(parts) > 3 else 0 + except (IndexError, ValueError): + self._protocol_error(f"malformed %begin: {parts}") + return + + try: + ctx = self._pending.popleft() + except IndexError: + # No pending command - this is likely from a hook action. + # Skip this block instead of killing the connection. + logger.debug( + "Unexpected %%begin id=%d (hook execution?), skipping block", cmd_id + ) + self.state = ParserState.SKIPPING + return + + ctx.cmd_id = cmd_id + ctx.tmux_time = tmux_time + ctx.flags = flags + ctx.start_time = time.monotonic() + self._current = ctx + self.state = ParserState.IN_COMMAND + + def _on_end_or_error(self, tag: str, parts: list[str]) -> None: + if self.state is ParserState.SKIPPING: + # End of skipped block - return to idle + logger.debug("Skipped block ended with %s", tag) + self.state = ParserState.IDLE + return + if self.state is not ParserState.IN_COMMAND or self._current is None: + self._protocol_error(f"unexpected {tag}") + return + + ctx = self._current + ctx.exit_status = ExitStatus.OK if tag == "%end" else ExitStatus.ERROR + ctx.end_time = time.monotonic() + + # Copy tmux_time/flags if provided on the closing tag + try: + if len(parts) > 1: + ctx.tmux_time = int(parts[1]) + if len(parts) > 3: + ctx.flags = int(parts[3]) + except ValueError: + pass + + if ctx.exit_status is ExitStatus.ERROR and ctx.stdout and not ctx.stderr: + ctx.stderr, ctx.stdout = ctx.stdout, [] + + ctx.signal_done() + self._current = None + self.state = ParserState.IDLE + + def _on_notification(self, line: str, parts: list[str]) -> None: + notif = _parse_notification(line, parts) + try: + self._notif_queue.put_nowait(notif) + except queue.Full: + self._dropped_notifications += 1 + if self._dropped_notifications & (self._dropped_notifications - 1) == 0: + logger.warning( + "Control Mode notification queue full; dropped=%d", + self._dropped_notifications, + ) + + def mark_dead(self, reason: str) -> None: + """Mark protocol as unusable and fail pending commands.""" + self.state = ParserState.DEAD + self._last_error = reason + err = exc.ControlModeConnectionError(reason) + + if self._current: + # Special-case kill-* commands: tmux may close control socket immediately. + if any( + kill_cmd in self._current.argv + for kill_cmd in ("kill-server", "kill-session") + ): + self._current.exit_status = ExitStatus.OK + self._current.end_time = time.monotonic() + self._current.signal_done() + else: + self._current.error = err + self._current.signal_done() + self._current = None + + while self._pending: + ctx = self._pending.popleft() + ctx.error = err + ctx.signal_done() + + def _protocol_error(self, reason: str) -> None: + logger.error("Control Mode protocol error: %s", reason) + self.mark_dead(reason) + + # Accessors --------------------------------------------------------- + def get_notification(self, timeout: float | None = None) -> Notification | None: + """Return the next notification or ``None`` if none available.""" + try: + return self._notif_queue.get(timeout=timeout) + except queue.Empty: + return None + + def get_stats(self, *, restarts: int) -> EngineStats: + """Return diagnostic counters for the protocol.""" + in_flight = (1 if self._current else 0) + len(self._pending) + return EngineStats( + in_flight=in_flight, + notif_queue_depth=self._notif_queue.qsize(), + dropped_notifications=self._dropped_notifications, + restarts=restarts, + last_error=self._last_error, + last_activity=self._last_activity, + ) + + def build_result(self, ctx: CommandContext) -> CommandResult: + """Convert a completed context into a :class:`CommandResult`.""" + exit_status = ctx.exit_status or ExitStatus.OK + return CommandResult( + argv=ctx.argv, + stdout=_trim_lines(ctx.stdout), + stderr=_trim_lines(ctx.stderr), + exit_status=exit_status, + cmd_id=ctx.cmd_id, + start_time=ctx.start_time, + end_time=ctx.end_time, + tmux_time=ctx.tmux_time, + flags=ctx.flags, + ) diff --git a/src/libtmux/_internal/engines/subprocess_engine.py b/src/libtmux/_internal/engines/subprocess_engine.py new file mode 100644 index 000000000..3f8ce455a --- /dev/null +++ b/src/libtmux/_internal/engines/subprocess_engine.py @@ -0,0 +1,85 @@ +"""Subprocess engine for libtmux.""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import typing as t + +from libtmux import exc +from libtmux._internal.engines.base import CommandResult, Engine, ExitStatus + +logger = logging.getLogger(__name__) + + +class SubprocessEngine(Engine): + """Engine that runs tmux commands via subprocess.""" + + def run_result( + self, + cmd: str, + cmd_args: t.Sequence[str | int] | None = None, + server_args: t.Sequence[str | int] | None = None, + timeout: float | None = None, + ) -> CommandResult: + """Run a tmux command using ``subprocess.Popen``.""" + tmux_bin = shutil.which("tmux") + if not tmux_bin: + raise exc.TmuxCommandNotFound + + full_cmd: list[str | int] = [tmux_bin] + if server_args: + full_cmd += list(server_args) + full_cmd.append(cmd) + if cmd_args: + full_cmd += list(cmd_args) + + full_cmd_str = [str(c) for c in full_cmd] + + try: + process = subprocess.Popen( + full_cmd_str, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="backslashreplace", + ) + stdout_str, stderr_str = process.communicate(timeout=timeout) + returncode = process.returncode + except subprocess.TimeoutExpired: + process.kill() + process.wait() + msg = "tmux subprocess timed out" + raise exc.SubprocessTimeout(msg) from None + except Exception: + logger.exception(f"Exception for {subprocess.list2cmdline(full_cmd_str)}") + raise + + stdout_split = stdout_str.split("\n") + while stdout_split and stdout_split[-1] == "": + stdout_split.pop() + + stderr_split = stderr_str.split("\n") + stderr = list(filter(None, stderr_split)) + + if "has-session" in full_cmd_str and len(stderr) and not stdout_split: + stdout = [stderr[0]] + else: + stdout = stdout_split + + logger.debug( + "self.stdout for {cmd}: {stdout}".format( + cmd=" ".join(full_cmd_str), + stdout=stdout, + ), + ) + + exit_status = ExitStatus.OK if returncode == 0 else ExitStatus.ERROR + + return CommandResult( + argv=full_cmd_str, + stdout=stdout, + stderr=stderr, + exit_status=exit_status, + ) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 60a3b49c7..f00f78403 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -233,8 +233,9 @@ class tmux_cmd: ... ) ... - >>> print(f'tmux command returned {" ".join(proc.stdout)}') - tmux command returned 2 + >>> session_name = " ".join(proc.stdout) + >>> print(f'tmux command returned session: {session_name.isdigit()}') + tmux command returned session: True Equivalent to: @@ -248,7 +249,21 @@ class tmux_cmd: Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any) -> None: + def __init__( + self, + *args: t.Any, + cmd: list[str] | None = None, + stdout: list[str] | None = None, + stderr: list[str] | None = None, + returncode: int | None = None, + ) -> None: + if cmd is not None: + self.cmd = cmd + self.stdout = stdout or [] + self.stderr = stderr or [] + self.returncode = returncode + return + tmux_bin = shutil.which("tmux") if not tmux_bin: raise exc.TmuxCommandNotFound @@ -267,7 +282,7 @@ def __init__(self, *args: t.Any) -> None: text=True, errors="backslashreplace", ) - stdout, stderr = self.process.communicate() + stdout_str, stderr_str = self.process.communicate() returncode = self.process.returncode except Exception: logger.exception(f"Exception for {subprocess.list2cmdline(cmd)}") @@ -275,12 +290,12 @@ def __init__(self, *args: t.Any) -> None: self.returncode = returncode - stdout_split = stdout.split("\n") + stdout_split = stdout_str.split("\n") # remove trailing newlines from stdout while stdout_split and stdout_split[-1] == "": stdout_split.pop() - stderr_split = stderr.split("\n") + stderr_split = stderr_str.split("\n") self.stderr = list(filter(None, stderr_split)) # filter empty values if "has-session" in cmd and len(self.stderr) and not stdout_split: diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index c5363b5ef..e01efe1a2 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -92,6 +92,60 @@ class WaitTimeout(LibTmuxException): """Function timed out without meeting condition.""" +class ControlModeTimeout(LibTmuxException): + """tmux control-mode command did not return before the configured timeout. + + This is a **terminal failure** - the operation took too long and is NOT + automatically retried. If you expect slow operations, increase the timeout + parameter when creating the engine or calling commands. + + This is distinct from :class:`ControlModeConnectionError`, which indicates + a transient connection failure (like BrokenPipeError) and IS automatically + retried up to ``max_retries`` times. + + Raised by :class:`~libtmux._internal.engines.control_mode.ControlModeEngine` + when a command block fails to finish within the timeout. The engine will + close and restart the control client after emitting this error, but will NOT + retry the timed-out command. + + If commands timeout frequently, increase the timeout:: + + engine = ControlModeEngine(command_timeout=30.0) + server = Server(engine=engine) + + Or override per-command:: + + server.cmd("slow-command", timeout=60.0) + """ + + +class ControlModeProtocolError(LibTmuxException): + """Protocol-level error while parsing the control-mode stream. + + Indicates malformed `%begin/%end/%error` framing or an unexpected parser + state. The control client is marked dead and must be restarted. + """ + + +class ControlModeConnectionError(LibTmuxException): + """Control-mode connection was lost unexpectedly (EOF/broken pipe). + + This is a **retriable error** - the engine will automatically retry the + command up to ``max_retries`` times (default: 1) after restarting the + control client connection. + + This is distinct from :class:`ControlModeTimeout`, which indicates the + operation took too long and is NOT automatically retried. + + Raised when writing to the control-mode process fails with BrokenPipeError + or when unexpected EOF is encountered. + """ + + +class SubprocessTimeout(LibTmuxException): + """tmux subprocess exceeded the allowed timeout for the invoked command.""" + + class VariableUnpackingError(LibTmuxException): """Error unpacking variable.""" diff --git a/src/libtmux/hooks.py b/src/libtmux/hooks.py index c0c28fd09..c7630bf0a 100644 --- a/src/libtmux/hooks.py +++ b/src/libtmux/hooks.py @@ -38,7 +38,7 @@ Hooks, ) from libtmux._internal.sparse_array import SparseArray -from libtmux.common import CmdMixin, has_lt_version +from libtmux.common import CmdMixin from libtmux.constants import ( DEFAULT_OPTION_SCOPE, HOOK_SCOPE_FLAG_MAP, @@ -89,13 +89,7 @@ def run_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2"): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -168,13 +162,7 @@ def set_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2"): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -221,13 +209,7 @@ def unset_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2"): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd( "set-hook", @@ -286,13 +268,7 @@ def show_hooks( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2"): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) cmd = self.cmd("show-hooks", *flags) output = cmd.stdout @@ -344,13 +320,7 @@ def _show_hook( assert scope in HOOK_SCOPE_FLAG_MAP flag = HOOK_SCOPE_FLAG_MAP[scope] - if flag in {"-p", "-w"} and has_lt_version("3.2"): - warnings.warn( - "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", - stacklevel=2, - ) - else: - flags += (flag,) + flags += (flag,) flags += (hook,) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 932f969e1..f5ec4ff67 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -8,7 +8,6 @@ from collections.abc import Iterable from libtmux import exc -from libtmux.common import tmux_cmd from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -182,28 +181,24 @@ def fetch_objs( list_cmd: ListCmd, list_extra_args: ListExtraArgs = None, ) -> OutputsRaw: - """Fetch a listing of raw data from a tmux command.""" - formats = list(Obj.__dataclass_fields__.keys()) - - cmd_args: list[str | int] = [] + """Fetch a listing of raw data from a tmux command. - if server.socket_name: - cmd_args.insert(0, f"-L{server.socket_name}") - if server.socket_path: - cmd_args.insert(0, f"-S{server.socket_path}") + Routes all commands through the server's engine, enabling: + - Control mode persistent connection for fetch operations + - Engine-specific validation (e.g., control_session preflight checks) + - Consistent error handling across all tmux operations + """ + formats = list(Obj.__dataclass_fields__.keys()) tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] - tmux_cmds = [ - *cmd_args, - list_cmd, - ] - + # Build command arguments for the list command + cmd_args: list[str] = [] if list_extra_args is not None and isinstance(list_extra_args, Iterable): - tmux_cmds.extend(list(list_extra_args)) - - tmux_cmds.append("-F{}".format("".join(tmux_formats))) + cmd_args.extend(list(list_extra_args)) + cmd_args.append("-F{}".format("".join(tmux_formats))) - proc = tmux_cmd(*tmux_cmds) # output + # Route through engine via server.cmd() + proc = server.cmd(list_cmd, *cmd_args) if proc.stderr: raise exc.LibTmuxException(proc.stderr) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 40964f39f..bb5a47812 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -10,6 +10,7 @@ import dataclasses import logging import pathlib +import time import typing as t import warnings @@ -62,24 +63,26 @@ class Pane( Examples -------- - >>> pane - Pane(%1 Window(@1 1:..., Session($1 ...))) + >>> pane # doctest: +ELLIPSIS + Pane(%... Window(@... ..., Session($... ...))) >>> pane in window.panes True - >>> pane.window - Window(@1 1:..., Session($1 ...)) + >>> pane.window # doctest: +ELLIPSIS + Window(@... ..., Session($... ...)) - >>> pane.session - Session($1 ...) + >>> pane.session # doctest: +ELLIPSIS + Session($... ...) The pane can be used as a context manager to ensure proper cleanup: - >>> with window.split() as pane: - ... pane.send_keys('echo "Hello"') - ... # Do work with the pane - ... # Pane will be killed automatically when exiting the context + .. code-block:: python + + with window.split() as pane: + pane.send_keys('echo "Hello"') + # Do work with the pane + # Pane will be killed automatically when exiting the context Notes ----- @@ -188,14 +191,20 @@ def cmd( Examples -------- - >>> pane.cmd('split-window', '-P').stdout[0] - 'libtmux...:...' + .. code-block:: python + + pane.cmd('split-window', '-P').stdout[0] + # 'libtmux...:...' From raw output to an enriched `Pane` object: - >>> Pane.from_pane_id(pane_id=pane.cmd( - ... 'split-window', '-P', '-F#{pane_id}').stdout[0], server=pane.server) - Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + .. code-block:: python + + Pane.from_pane_id( + pane_id=pane.cmd('split-window', '-P', '-F#{pane_id}').stdout[0], + server=pane.server + ) + # Pane(%... Window(@... ...:..., Session($1 libtmux_...))) Parameters ---------- @@ -356,7 +365,28 @@ def capture_pane( cmd.extend(["-S", str(start)]) if end is not None: cmd.extend(["-E", str(end)]) - return self.cmd(*cmd).stdout + output = self.cmd(*cmd).stdout + + def _trim(lines: list[str]) -> list[str]: + # Match engine trimming: remove trailing empty or whitespace-only lines + trimmed = list(lines) + while trimmed and trimmed[-1].strip() == "": + trimmed.pop() + return trimmed + + output = _trim(output) + + # In control mode, capture-pane can race the shell: the first capture + # right after send-keys may return only the echoed command. Retry + # briefly to allow the prompt/output to land. + engine_name = self.server.engine.__class__.__name__ + if engine_name == "ControlModeEngine" and not output: + deadline = time.monotonic() + 0.35 + while not output and time.monotonic() < deadline: + time.sleep(0.05) + output = _trim(self.cmd(*cmd).stdout) + + return output def send_keys( self, diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index cc45ce7a9..4a5afbd99 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -13,6 +13,9 @@ import pytest from libtmux import exc +from libtmux._internal.engines.base import Engine +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux._internal.engines.subprocess_engine import SubprocessEngine from libtmux.server import Server from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import get_test_session_name, namer @@ -114,6 +117,7 @@ def server( request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, config_file: pathlib.Path, + engine_name: str, ) -> Server: """Return new, temporary :class:`libtmux.Server`. @@ -140,7 +144,23 @@ def server( >>> result.assert_outcomes(passed=1) """ - server = Server(socket_name=f"libtmux_test{next(namer)}") + socket_name = f"libtmux_test{next(namer)}" + + # Bootstrap: Create "tmuxp" session BEFORE control mode starts + # This allows control mode to attach to it, avoiding internal session creation + if engine_name == "control": + import subprocess + + subprocess.run( + ["tmux", "-L", socket_name, "new-session", "-d", "-s", "tmuxp"], + check=False, # Ignore if already exists + capture_output=True, + ) + engine = _build_engine(engine_name, control_session="tmuxp") + else: + engine = _build_engine(engine_name) + + server = Server(socket_name=socket_name, engine=engine) def fin() -> None: server.kill() @@ -310,5 +330,34 @@ def fin() -> None: Server, on_init=on_init, socket_name_factory=socket_name_factory, + engine=_build_engine(request.getfixturevalue("engine_name")), ), ) + + +@pytest.fixture +def engine_name(request: pytest.FixtureRequest) -> str: + """Engine selector fixture, driven by CLI or parametrization.""" + if hasattr(request, "param"): + return t.cast(str, request.param) + try: + return t.cast(str, request.config.getoption("--engine")) + except ValueError: + # Option may not be registered when libtmux is used purely as a plugin + # dependency in downstream projects. Default to subprocess for safety. + return "subprocess" + + +def _build_engine(engine_name: str, control_session: str | None = None) -> Engine: + """Return engine instance by name. + + Parameters + ---------- + engine_name : str + Name of engine: "control" or "subprocess" + control_session : str, optional + For control mode: session name to attach to instead of creating internal session + """ + if engine_name == "control": + return ControlModeEngine(control_session=control_session) + return SubprocessEngine() diff --git a/src/libtmux/server.py b/src/libtmux/server.py index b09c58c50..02bc1b386 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -16,6 +16,8 @@ import warnings from libtmux import exc, formats +from libtmux._internal.engines.base import ServerContext +from libtmux._internal.engines.subprocess_engine import SubprocessEngine from libtmux._internal.query_list import QueryList from libtmux.common import tmux_cmd from libtmux.constants import OptionScope @@ -40,6 +42,7 @@ from typing_extensions import Self + from libtmux._internal.engines.base import Engine from libtmux._internal.types import StrPath DashLiteral: TypeAlias = t.Literal["-"] @@ -78,17 +81,17 @@ class Server( >>> server Server(socket_name=libtmux_test...) - >>> server.sessions - [Session($1 ...)] + >>> server.sessions # doctest: +ELLIPSIS + [Session($... ...)] - >>> server.sessions[0].windows - [Window(@1 1:..., Session($1 ...))] + >>> server.sessions[0].windows # doctest: +ELLIPSIS + [Window(@... ..., Session($... ...))] - >>> server.sessions[0].active_window - Window(@1 1:..., Session($1 ...)) + >>> server.sessions[0].active_window # doctest: +ELLIPSIS + Window(@... ..., Session($... ...)) - >>> server.sessions[0].active_pane - Pane(%1 Window(@1 1:..., Session($1 ...))) + >>> server.sessions[0].active_pane # doctest: +ELLIPSIS + Pane(%... Window(@... ..., Session($... ...))) The server can be used as a context manager to ensure proper cleanup: @@ -137,12 +140,17 @@ def __init__( colors: int | None = None, on_init: t.Callable[[Server], None] | None = None, socket_name_factory: t.Callable[[], str] | None = None, + engine: Engine | None = None, **kwargs: t.Any, ) -> None: EnvironmentMixin.__init__(self, "-g") self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] + if engine is None: + engine = SubprocessEngine() + self.engine = engine + if socket_path is not None: self.socket_path = socket_path elif socket_name is not None: @@ -166,6 +174,15 @@ def __init__( if colors: self.colors = colors + # Bind engine to server context for hook calls + self.engine.bind( + ServerContext( + socket_name=self.socket_name, + socket_path=str(self.socket_path) if self.socket_path else None, + config_file=self.config_file, + ), + ) + if on_init is not None: on_init(self) @@ -205,6 +222,12 @@ def is_alive(self) -> bool: >>> tmux = Server(socket_name="no_exist") >>> assert not tmux.is_alive() """ + # Use engine hook to allow engines to probe without bootstrapping. + probe_result = self.engine.probe_server_alive() + if probe_result is not None: + return probe_result + + # Default: run list-sessions through the engine. try: res = self.cmd("list-sessions") except Exception: @@ -221,23 +244,58 @@ def raise_if_dead(self) -> None: ... print(type(e)) """ + server_args = tuple(self._build_server_args()) + + # Use engine hook to allow engines to probe without bootstrapping. + probe_result = self.engine.probe_server_alive() + if probe_result is not None: + if not probe_result: + tmux_bin_probe = shutil.which("tmux") or "tmux" + raise subprocess.CalledProcessError( + returncode=1, + cmd=[tmux_bin_probe, *server_args, "list-sessions"], + ) + return + + # Default: run list-sessions through the engine. tmux_bin = shutil.which("tmux") if tmux_bin is None: raise exc.TmuxCommandNotFound - cmd_args: list[str] = ["list-sessions"] + proc = self.cmd("list-sessions") + if proc.returncode is not None and proc.returncode != 0: + raise subprocess.CalledProcessError( + returncode=proc.returncode, + cmd=[tmux_bin, *server_args, "list-sessions"], + ) + + # + # Command + # + def _build_server_args(self) -> list[str]: + """Return tmux server args based on socket/config settings.""" + server_args: list[str] = [] if self.socket_name: - cmd_args.insert(0, f"-L{self.socket_name}") + server_args.append(f"-L{self.socket_name}") if self.socket_path: - cmd_args.insert(0, f"-S{self.socket_path}") + server_args.append(f"-S{self.socket_path}") if self.config_file: - cmd_args.insert(0, f"-f{self.config_file}") + server_args.append(f"-f{self.config_file}") + return server_args - subprocess.check_call([tmux_bin, *cmd_args]) + def _probe_server(self) -> int: + """Check server liveness without bootstrapping control mode.""" + tmux_bin = shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + + result = subprocess.run( + [tmux_bin, *self._build_server_args(), "list-sessions"], + check=False, + capture_output=True, + ) + return result.returncode - # - # Command - # def cmd( self, cmd: str, @@ -291,25 +349,24 @@ def cmd( Renamed from ``.tmux`` to ``.cmd``. """ - svr_args: list[str | int] = [cmd] - cmd_args: list[str | int] = [] + server_args: list[str | int] = [] if self.socket_name: - svr_args.insert(0, f"-L{self.socket_name}") + server_args.append(f"-L{self.socket_name}") if self.socket_path: - svr_args.insert(0, f"-S{self.socket_path}") + server_args.append(f"-S{self.socket_path}") if self.config_file: - svr_args.insert(0, f"-f{self.config_file}") + server_args.append(f"-f{self.config_file}") if self.colors: if self.colors == 256: - svr_args.insert(0, "-2") + server_args.append("-2") elif self.colors == 88: - svr_args.insert(0, "-8") + server_args.append("-8") else: raise exc.UnknownColorOption - cmd_args = ["-t", str(target), *args] if target is not None else [*args] + cmd_args = ["-t", str(target), *args] if target is not None else list(args) - return tmux_cmd(*svr_args, *cmd_args) + return self.engine.run(cmd, cmd_args=cmd_args, server_args=server_args) @property def attached_sessions(self) -> list[Session]: @@ -325,10 +382,16 @@ def attached_sessions(self) -> list[Session]: list[:class:`Session`] Sessions that are attached. """ - return self.sessions.filter(session_attached__noeq="1") + sessions = list(self.sessions.filter(session_attached__noeq="1")) + + # Let the engine hide its own internal client if it wants to. + return self.engine.filter_sessions(sessions) def has_session(self, target_session: str, exact: bool = True) -> bool: - """Return True if session exists. + """Return True if session exists (excluding internal engine sessions). + + Internal sessions used by engines for connection management are + excluded to maintain engine transparency. Parameters ---------- @@ -348,6 +411,11 @@ def has_session(self, target_session: str, exact: bool = True) -> bool: """ session_check_name(target_session) + # Never report internal engine sessions as existing + internal_names = self._get_internal_session_names() + if target_session in internal_names: + return False + if exact: target_session = f"={target_session}" @@ -413,6 +481,12 @@ def switch_client(self, target_session: str) -> None: """ session_check_name(target_session) + # Use engine hook to check if switch-client is meaningful. + # For control mode, this ensures there is at least one non-control client. + if not self.engine.can_switch_client(): + msg = "no current client" + raise exc.LibTmuxException(msg) + proc = self.cmd("switch-client", target=target_session) if proc.stderr: @@ -436,6 +510,78 @@ def attach_session(self, target_session: str | None = None) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def connect(self, session_name: str) -> Session: + """Connect to a session, creating if it doesn't exist. + + Returns an existing session if found, otherwise creates a new detached session. + + Parameters + ---------- + session_name : str + Name of the session to connect to. + + Returns + ------- + :class:`Session` + The connected or newly created session. + + Raises + ------ + :exc:`exc.BadSessionName` + If the session name is invalid (contains '.' or ':'). + :exc:`exc.LibTmuxException` + If tmux returns an error. + + Examples + -------- + >>> session = server.connect('my_session') + >>> session.name + 'my_session' + + Calling again returns the same session: + + >>> session2 = server.connect('my_session') + >>> session2.session_id == session.session_id + True + """ + session_check_name(session_name) + + # Check if session already exists + if self.has_session(session_name): + session = self.sessions.get(session_name=session_name) + if session is None: + msg = "Session lookup failed after has_session passed" + raise exc.LibTmuxException(msg) + return session + + # Session doesn't exist, create it + # Save and clear TMUX env var (same as new_session) + env = os.environ.get("TMUX") + if env: + del os.environ["TMUX"] + + proc = self.cmd( + "new-session", + "-d", + f"-s{session_name}", + "-P", + "-F#{session_id}", + ) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + session_id = proc.stdout[0] + + # Restore TMUX env var + if env: + os.environ["TMUX"] = env + + return Session.from_session_id( + server=self, + session_id=session_id, + ) + def new_session( self, session_name: str | None = None, @@ -597,14 +743,51 @@ def new_session( # # Relations # + def _get_internal_session_names(self) -> set[str]: + """Get session names used internally by the engine for management.""" + internal_names: set[str] = set( + getattr(self.engine, "internal_session_names", set()), + ) + try: + return set(internal_names) + except Exception: # pragma: no cover - defensive + return set() + @property def sessions(self) -> QueryList[Session]: - """Sessions contained in server. + """Sessions contained in server (excluding internal engine sessions). + + Internal sessions are used by engines for connection management + (e.g., control mode maintains a persistent connection session). + These are automatically filtered to maintain engine transparency. + + For advanced debugging, use the internal :meth:`._sessions_all()` method. Can be accessed via :meth:`.sessions.get() ` and :meth:`.sessions.filter() ` """ + all_sessions = self._sessions_all() + + # Filter out internal engine sessions + internal_names = self._get_internal_session_names() + filtered_sessions = [ + s for s in all_sessions if s.session_name not in internal_names + ] + + return QueryList(filtered_sessions) + + def _sessions_all(self) -> QueryList[Session]: + """Return all sessions including internal engine sessions. + + Used internally for engine management and advanced debugging. + Most users should use the :attr:`.sessions` property instead. + + Returns + ------- + QueryList[Session] + All sessions including internal ones used by engines. + """ sessions: list[Session] = [] try: @@ -613,7 +796,11 @@ def sessions(self) -> QueryList[Session]: server=self, ): sessions.append(Session(server=self, **obj)) # noqa: PERF401 + except (exc.ControlModeConnectionError, exc.ControlModeTimeout): + # Propagate control mode connection/timeout errors + raise except Exception: + # Catch other exceptions (e.g., no sessions exist) pass return QueryList(sessions) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 003beeaca..9332cc7c8 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -585,11 +585,21 @@ def kill_window(self, target_window: str | None = None) -> None: :exc:`libtmux.exc.LibTmuxException` If tmux returns an error. """ + target: str | None = None if target_window: + # Scope the target to this session so control-mode's internal + # client session does not steal context. + session_target = self.session_id if isinstance(target_window, int): - target = f"{self.window_name}:{target_window}" + target = f"{session_target}:{target_window}" else: - target = f"{target_window}" + # Allow fully-qualified targets and window IDs to pass through. + if ":" in target_window or target_window.startswith("@"): + target = target_window + else: + target = f"{session_target}:{target_window}" + else: + target = self.session_id proc = self.cmd("kill-window", target=target) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..51e11bcd0 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,30 @@ +"""Test helpers for control-mode flakiness handling.""" + +from __future__ import annotations + +import time +import typing as t + +from libtmux.pane import Pane + + +def wait_for_line( + pane: Pane, + predicate: t.Callable[[str], bool], + *, + timeout: float = 1.0, + interval: float = 0.05, +) -> list[str]: + """Poll capture_pane until a line satisfies ``predicate``. + + Returns the final capture buffer (may be empty if timeout elapses). + """ + deadline = time.monotonic() + timeout + last: list[str] = [] + while time.monotonic() < deadline: + captured = pane.capture_pane() + last = [captured] if isinstance(captured, str) else list(captured) + if any(predicate(line) for line in last): + break + time.sleep(interval) + return last diff --git a/tests/legacy_api/test_session.py b/tests/legacy_api/test_session.py index a9560824e..3855decf3 100644 --- a/tests/legacy_api/test_session.py +++ b/tests/legacy_api/test_session.py @@ -14,6 +14,7 @@ from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import namer from libtmux.window import Window +from tests.helpers import wait_for_line if t.TYPE_CHECKING: from libtmux.server import Server @@ -280,4 +281,11 @@ def test_new_window_with_environment( assert pane is not None for k, v in environment.items(): pane.send_keys(f"echo ${k}") - assert pane.capture_pane()[-2] == v + + def _match(line: str, expected: str = v) -> bool: + stripped = line.strip() + # Match exact value or value after shell prompt ($ prefix) + return stripped == expected or stripped == f"$ {expected}" + + lines = wait_for_line(pane, _match) + assert any(_match(line) for line in lines) diff --git a/tests/legacy_api/test_window.py b/tests/legacy_api/test_window.py index bd87f349c..ff7bd4596 100644 --- a/tests/legacy_api/test_window.py +++ b/tests/legacy_api/test_window.py @@ -4,7 +4,6 @@ import logging import shutil -import time import typing as t import pytest @@ -14,6 +13,7 @@ from libtmux.pane import Pane from libtmux.server import Server from libtmux.window import Window +from tests.helpers import wait_for_line if t.TYPE_CHECKING: from libtmux.session import Session @@ -382,8 +382,13 @@ def test_split_window_with_environment( environment=environment, ) assert pane is not None - # wait a bit for the prompt to be ready as the test gets flaky otherwise - time.sleep(0.05) for k, v in environment.items(): pane.send_keys(f"echo ${k}") - assert pane.capture_pane()[-2] == v + + def _match(line: str, expected: str = v) -> bool: + stripped = line.strip() + # Match exact value or value after shell prompt ($ prefix) + return stripped == expected or stripped == f"$ {expected}" + + lines = wait_for_line(pane, _match) + assert any(_match(line) for line in lines) diff --git a/tests/test/test_retry.py b/tests/test/test_retry.py index ca09d8b4f..21c752a4a 100644 --- a/tests/test/test_retry.py +++ b/tests/test/test_retry.py @@ -29,7 +29,7 @@ def call_me_three_times() -> bool: end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert 0.9 <= (end - ini) <= 1.3 # Allow for small timing variations def test_function_times_out() -> None: @@ -47,7 +47,7 @@ def never_true() -> bool: end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert 0.9 <= (end - ini) <= 1.3 # Allow for small timing variations def test_function_times_out_no_raise() -> None: diff --git a/tests/test_control_client_logs.py b/tests/test_control_client_logs.py new file mode 100644 index 000000000..24610b586 --- /dev/null +++ b/tests/test_control_client_logs.py @@ -0,0 +1,133 @@ +"""Diagnostic tests using raw control client logs.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._internal.engines.control_protocol import CommandContext, ControlProtocol + + +@pytest.mark.engines(["control"]) +def test_control_client_lists_clients( + control_client_logs: tuple[t.Any, ControlProtocol], +) -> None: + """Raw control client should report itself with control-mode flag.""" + proc, proto = control_client_logs + + assert proc.stdin is not None + list_ctx = CommandContext( + argv=[ + "tmux", + "list-clients", + "-F", + "#{client_pid} #{client_flags} #{session_name}", + ], + ) + proto.register_command(list_ctx) + detach_ctx = CommandContext(argv=["tmux", "detach-client"]) + proto.register_command(detach_ctx) + proc.stdin.write('list-clients -F"#{client_pid} #{client_flags} #{session_name}"\n') + proc.stdin.write("detach-client\n") + proc.stdin.flush() + + stdout_data, _ = proc.communicate(timeout=5) + for line in stdout_data.splitlines(): + proto.feed_line(line.rstrip("\n")) + + assert list_ctx.done.wait(timeout=0.5) + result = proto.build_result(list_ctx) + + saw_control_flag = any( + len(parts := line.split()) >= 2 + and ("C" in parts[1] or "control-mode" in parts[1]) + for line in result.stdout + ) + assert saw_control_flag + + +@pytest.mark.engines(["control"]) +def test_control_client_capture_stream_parses( + control_client_logs: tuple[t.Any, ControlProtocol], +) -> None: + """Ensure ControlProtocol can parse raw stream from the logged control client.""" + proc, proto = control_client_logs + assert proc.stdin is not None + + display_ctx = CommandContext(argv=["tmux", "display-message", "-p", "hello"]) + proto.register_command(display_ctx) + detach_ctx = CommandContext(argv=["tmux", "detach-client"]) + proto.register_command(detach_ctx) + proc.stdin.write("display-message -p hello\n") + proc.stdin.write("detach-client\n") + proc.stdin.flush() + + stdout_data, _ = proc.communicate(timeout=5) + + for line in stdout_data.splitlines(): + proto.feed_line(line.rstrip("\n")) + + assert display_ctx.done.wait(timeout=0.5) + result = proto.build_result(display_ctx) + assert "hello" in result.stdout or "hello" in "".join(result.stdout) + + +def test_control_client_notification_parsing( + control_client_logs: tuple[t.Any, ControlProtocol], +) -> None: + """Control client log stream should produce notifications.""" + proc, proto = control_client_logs + assert proc.stdin is not None + + ctx = CommandContext(argv=["tmux", "display-message", "-p", "ping"]) + proto.register_command(ctx) + # send a trivial command and rely on session-changed notification from attach + proc.stdin.write("display-message -p ping\n") + proc.stdin.write("detach-client\n") + proc.stdin.flush() + + stdout_data, _ = proc.communicate(timeout=5) + for line in stdout_data.splitlines(): + proto.feed_line(line.rstrip("\n")) + + notif = proto.get_notification(timeout=0.1) + assert notif is not None + assert notif.kind.name in {"SESSION_CHANGED", "CLIENT_SESSION_CHANGED", "RAW"} + + +@pytest.mark.engines(["control"]) +def test_control_client_lists_control_flag( + control_client_logs: tuple[t.Any, ControlProtocol], +) -> None: + """list-clients should show control client with C flag on tmux >= 3.2.""" + proc, proto = control_client_logs + + assert proc.stdin is not None + list_ctx = CommandContext( + argv=[ + "tmux", + "list-clients", + "-F", + "#{client_pid} #{client_flags} #{session_name}", + ], + ) + proto.register_command(list_ctx) + proc.stdin.write('list-clients -F"#{client_pid} #{client_flags} #{session_name}"\n') + # Register detach to avoid protocol error on trailing %begin/%end + detach_ctx = CommandContext(argv=["tmux", "detach-client"]) + proto.register_command(detach_ctx) + proc.stdin.write("detach-client\n") + proc.stdin.flush() + + stdout_data, _ = proc.communicate(timeout=5) + for line in stdout_data.splitlines(): + proto.feed_line(line.rstrip("\n")) + + assert list_ctx.done.wait(timeout=0.5) + result = proto.build_result(list_ctx) + assert any( + len(parts := line.split()) >= 2 + and ("C" in parts[1] or "control-mode" in parts[1]) + for line in result.stdout + ) diff --git a/tests/test_control_mode_engine.py b/tests/test_control_mode_engine.py new file mode 100644 index 000000000..0ec7c8860 --- /dev/null +++ b/tests/test_control_mode_engine.py @@ -0,0 +1,674 @@ +"""Tests for ControlModeEngine.""" + +from __future__ import annotations + +import io +import pathlib +import queue +import threading +import time +import typing as t +from collections import deque +from dataclasses import dataclass + +import pytest + +from libtmux import exc +from libtmux._internal.engines.base import ExitStatus +from libtmux._internal.engines.control_mode import ControlModeEngine, _ControlProcess +from libtmux._internal.engines.control_protocol import ControlProtocol +from libtmux.server import Server + + +def test_control_mode_engine_basic(tmp_path: pathlib.Path) -> None: + """Test basic functionality of ControlModeEngine.""" + socket_path = tmp_path / "tmux-control-mode-test" + engine = ControlModeEngine() + + # Server should auto-start engine on first cmd + server = Server(socket_path=socket_path, engine=engine) + + # kill server if exists (cleanup from previous runs if any) + if server.is_alive(): + server.kill() + + # new session + session = server.new_session(session_name="test_sess", kill_session=True) + assert session.name == "test_sess" + + # check engine process is running + assert engine.process is not None + assert engine.process.poll() is None + + # list sessions + # Control mode bootstrap session is now filtered from server.sessions + sessions = server.sessions + assert len(sessions) == 1 + session_names = [s.name for s in sessions] + assert "test_sess" in session_names + + # Verify bootstrap session exists but is filtered (use internal method) + all_sessions = server._sessions_all() + all_session_names = [s.name for s in all_sessions] + # Internal session now uses UUID-based name: libtmux_ctrl_XXXXXXXX + assert any( + name is not None and name.startswith("libtmux_ctrl_") + for name in all_session_names + ) + assert len(all_sessions) == 2 # test_sess + libtmux_ctrl_* + + # run a command that returns output + output_cmd = server.cmd("display-message", "-p", "hello") + assert output_cmd.stdout == ["hello"] + assert getattr(output_cmd, "exit_status", ExitStatus.OK) in ( + ExitStatus.OK, + 0, + ) + + # cleanup + server.kill() + # Engine process should terminate eventually (ControlModeEngine.close is called + # manually or via weakref/del) + # Server.kill() kills the tmux SERVER. The control mode client process should + # exit as a result. + + engine.process.wait(timeout=2) + assert engine.process.poll() is not None + + +def test_control_mode_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + """ControlModeEngine should surface timeouts and clean up the process.""" + + class BlockingStdout: + def __iter__(self) -> BlockingStdout: + return self + + def __next__(self) -> str: # pragma: no cover - simple block + time.sleep(0.05) + raise StopIteration + + class FakeProcess: + def __init__(self) -> None: + self.stdin: t.TextIO | None = io.StringIO() + self.stdout: t.Iterable[str] | None = BlockingStdout() + self.stderr: t.Iterable[str] | None = iter([]) + self._terminated: bool = False + self.pid: int | None = 1234 + + def terminate(self) -> None: # pragma: no cover - simple stub + self._terminated = True + + def kill(self) -> None: # pragma: no cover - simple stub + self._terminated = True + + def wait(self, timeout: float | None = None) -> int | None: # pragma: no cover + return 0 + + def poll(self) -> int | None: # pragma: no cover - simple stub + return 0 + + engine = ControlModeEngine(command_timeout=0.01, start_threads=False) + + fake_process: _ControlProcess = FakeProcess() + + def fake_start(server_args: t.Sequence[str | int] | None) -> None: + engine.tmux_bin = "tmux" + engine._server_args = tuple(server_args or ()) + engine.process = fake_process + + monkeypatch.setattr(engine, "_start_process", fake_start) + + with pytest.raises(exc.ControlModeTimeout): + engine.run("list-sessions") + + assert engine.process is None + + +def test_control_mode_per_command_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + """Per-call timeout should close process and raise ControlModeTimeout.""" + + class FakeProcess: + def __init__(self) -> None: + self.stdin: t.TextIO | None = io.StringIO() + self.stdout: t.Iterable[str] | None = iter([]) # no output + self.stderr: t.Iterable[str] | None = iter([]) + self._terminated: bool = False + self.pid: int | None = 5678 + + def terminate(self) -> None: + self._terminated = True + + def kill(self) -> None: + self._terminated = True + + def wait(self, timeout: float | None = None) -> int | None: + return 0 + + def poll(self) -> int | None: + return 0 + + engine = ControlModeEngine(command_timeout=5.0, start_threads=False) + + def fake_start(server_args: t.Sequence[str | int] | None) -> None: + engine.tmux_bin = "tmux" + engine._server_args = tuple(server_args or ()) + fake_proc: _ControlProcess = FakeProcess() + engine.process = fake_proc + + monkeypatch.setattr(engine, "_start_process", fake_start) + + with pytest.raises(exc.ControlModeTimeout): + engine.run("list-sessions", timeout=0.01) + + assert engine.process is None + + +def test_control_mode_custom_session_name(tmp_path: pathlib.Path) -> None: + """Control mode engine can use custom internal session name.""" + socket_path = tmp_path / "tmux-custom-session-test" + engine = ControlModeEngine(internal_session_name="my_control_session") + server = Server(socket_path=socket_path, engine=engine) + + # Cleanup if exists + if server.is_alive(): + server.kill() + + # Create user session + server.new_session(session_name="user_app") + + # Only user session visible via public API + assert len(server.sessions) == 1 + assert server.sessions[0].name == "user_app" + + # Custom internal session exists but is filtered + all_sessions = server._sessions_all() + all_names = [s.name for s in all_sessions] + assert "my_control_session" in all_names + assert "user_app" in all_names + assert len(all_sessions) == 2 + + # Cleanup + server.kill() + assert engine.process is not None + engine.process.wait(timeout=2) + + +def test_control_mode_control_session_existing(tmp_path: pathlib.Path) -> None: + """Control mode can attach to existing session (advanced opt-in).""" + socket_path = tmp_path / "tmux-attach-test" + + # Create session first with subprocess engine + from libtmux._internal.engines.subprocess_engine import SubprocessEngine + + subprocess_engine = SubprocessEngine() + server1 = Server(socket_path=socket_path, engine=subprocess_engine) + + if server1.is_alive(): + server1.kill() + + server1.new_session(session_name="shared_session") + + # Control mode attaches to existing session (no internal session created) + control_engine = ControlModeEngine(control_session="shared_session") + server2 = Server(socket_path=socket_path, engine=control_engine) + + # Should see the shared session + assert len(server2.sessions) == 1 + assert server2.sessions[0].name == "shared_session" + + # No internal session was created + all_sessions = server2._sessions_all() + assert len(all_sessions) == 1 # Only shared_session + + # Cleanup + server2.kill() + assert control_engine.process is not None + control_engine.process.wait(timeout=2) + + +class RestartFixture(t.NamedTuple): + """Fixture for restart/broken-pipe handling.""" + + test_id: str + should_raise: type[BaseException] + + +@pytest.mark.parametrize( + "case", + [ + RestartFixture( + test_id="broken_pipe_increments_restart", + should_raise=exc.ControlModeConnectionError, + ), + ], + ids=lambda c: c.test_id, +) +def test_write_line_broken_pipe_increments_restart( + case: RestartFixture, +) -> None: + """Broken pipe should raise ControlModeConnectionError and bump restarts.""" + + class FakeStdin(io.StringIO): + def write(self, _: str) -> int: # pragma: no cover - simple stub + raise BrokenPipeError + + def flush(self) -> None: # pragma: no cover - not reached + return None + + class FakeProcess: + def __init__(self) -> None: + self.stdin: t.TextIO | None = FakeStdin() + self.stdout: t.Iterable[str] | None = iter([]) + self.stderr: t.Iterable[str] | None = iter([]) + self._terminated: bool = False + self.pid: int | None = 9999 + + def terminate(self) -> None: + self._terminated = True + + def kill(self) -> None: # pragma: no cover - simple stub + self._terminated = True + + def wait(self, timeout: float | None = None) -> int | None: + return 0 + + def poll(self) -> int | None: + return 0 + + engine = ControlModeEngine() + fake_proc: _ControlProcess = FakeProcess() + engine.process = fake_proc + + with pytest.raises(case.should_raise): + engine._write_line("list-sessions", server_args=()) + assert engine._restarts == 1 + assert engine.process is None + + +class NotificationOverflowFixture(t.NamedTuple): + """Fixture for notification overflow handling.""" + + test_id: str + queue_size: int + overflow: int + + +@pytest.mark.parametrize( + "case", + [ + NotificationOverflowFixture( + test_id="iter_notifications_after_drop", + queue_size=1, + overflow=3, + ), + ], + ids=lambda c: c.test_id, +) +def test_iter_notifications_survives_overflow( + case: NotificationOverflowFixture, +) -> None: + """iter_notifications should continue yielding after queue drops.""" + engine = ControlModeEngine() + engine._protocol = ControlProtocol(notification_queue_size=case.queue_size) + + for _ in range(case.overflow): + engine._protocol.feed_line("%sessions-changed") + + stats = engine.get_stats() + assert stats.dropped_notifications >= case.overflow - case.queue_size + + notif_iter = engine.iter_notifications(timeout=0.01) + first = next(notif_iter, None) + assert first is not None + assert first.kind.name == "SESSIONS_CHANGED" + + +def test_set_client_flags_rejects_negative_pause() -> None: + """pause_after must be non-negative.""" + engine = ControlModeEngine(start_threads=False) + with pytest.raises(ValueError): + engine.set_client_flags(pause_after=-1) + + +def test_set_pane_flow_validates_state() -> None: + """Invalid flow state should raise.""" + engine = ControlModeEngine(start_threads=False) + with pytest.raises(ValueError): + engine.set_pane_flow("%1", state="bad") + + +class ScriptedStdin: + """Fake stdin that can optionally raise BrokenPipeError on write.""" + + def __init__(self, broken: bool = False) -> None: + """Initialize stdin. + + Parameters + ---------- + broken : bool + If True, write() and flush() raise BrokenPipeError. + """ + self._broken = broken + self._buf: list[str] = [] + + def write(self, data: str) -> int: + """Write data or raise BrokenPipeError if broken.""" + if self._broken: + raise BrokenPipeError + self._buf.append(data) + return len(data) + + def flush(self) -> None: + """Flush or raise BrokenPipeError if broken.""" + if self._broken: + raise BrokenPipeError + + +class ScriptedStdout: + """Queue-backed stdout that blocks like real subprocess I/O. + + Lines are fed from a background thread, simulating the pacing of real + process output. The iterator blocks on __next__ until a line is available + or EOF. + """ + + def __init__(self, lines: list[str], delay: float = 0.0) -> None: + """Initialize stdout iterator. + + Parameters + ---------- + lines : list[str] + Lines to emit (without trailing newlines). + delay : float + Optional delay between lines in seconds. + """ + self._queue: queue.Queue[str | None] = queue.Queue() + self._delay = delay + self._closed = threading.Event() + self._lines_fed = threading.Event() + + # Start feeder thread that pushes lines with optional delay + self._feeder = threading.Thread( + target=self._feed, + args=(lines,), + daemon=True, + ) + self._feeder.start() + + def _feed(self, lines: list[str]) -> None: + """Feed lines into the queue from a background thread.""" + for line in lines: + if self._delay > 0: + time.sleep(self._delay) + self._queue.put(line) + # Sentinel signals EOF + self._queue.put(None) + self._lines_fed.set() + + def __iter__(self) -> ScriptedStdout: + """Return iterator (self).""" + return self + + def __next__(self) -> str: + """Block until next line or raise StopIteration at EOF.""" + item = self._queue.get() # Blocks until available + if item is None: + self._closed.set() + raise StopIteration + return item + + def wait_until_fed(self, timeout: float | None = None) -> bool: + """Wait until all lines have been put into the queue.""" + return self._lines_fed.wait(timeout=timeout) + + def wait_until_consumed(self, timeout: float | None = None) -> bool: + """Wait until the iterator has reached EOF.""" + return self._closed.wait(timeout=timeout) + + +@dataclass +class ScriptedProcess: + """Fake control-mode process that plays back scripted stdout and errors. + + Uses ScriptedStdout (queue-backed iterator) instead of a tuple to match + real subprocess I/O semantics where reads are blocking/async. + """ + + stdin: t.TextIO | None + stdout: t.Iterable[str] | None + stderr: t.Iterable[str] | None + pid: int | None = 4242 + broken_on_write: bool = False + writes: list[str] | None = None + _stdin_impl: ScriptedStdin | None = None + _stdout_impl: ScriptedStdout | None = None + + def __init__( + self, + stdout_lines: list[str], + *, + broken_on_write: bool = False, + pid: int | None = 4242, + line_delay: float = 0.0, + ) -> None: + """Initialize scripted process. + + Parameters + ---------- + stdout_lines : list[str] + Lines to emit on stdout (without trailing newlines). + broken_on_write : bool + If True, writes to stdin raise BrokenPipeError. + pid : int | None + Process ID to report. + line_delay : float + Delay between stdout lines in seconds. Use for timeout tests. + """ + self._stdin_impl = ScriptedStdin(broken=broken_on_write) + self.stdin = t.cast(t.TextIO, self._stdin_impl) + self._stdout_impl = ScriptedStdout(stdout_lines, delay=line_delay) + self.stdout: t.Iterable[str] | None = self._stdout_impl + self.stderr: t.Iterable[str] | None = iter(()) + self.pid = pid + self.broken_on_write = broken_on_write + self.writes = [] + + def terminate(self) -> None: + """Stub terminate.""" + return None + + def kill(self) -> None: + """Stub kill.""" + return None + + def wait(self, timeout: float | None = None) -> int | None: + """Stub wait.""" + return 0 + + def poll(self) -> int | None: + """Stub poll.""" + return 0 + + def write_line(self, line: str) -> None: + """Record a write or raise BrokenPipe.""" + if self.broken_on_write: + raise BrokenPipeError + assert self.writes is not None + self.writes.append(line) + + def wait_stdout_fed(self, timeout: float | None = None) -> bool: + """Wait until all stdout lines have been queued.""" + if self._stdout_impl is None: + return True + return self._stdout_impl.wait_until_fed(timeout) + + def wait_stdout_consumed(self, timeout: float | None = None) -> bool: + """Wait until stdout iteration has reached EOF.""" + if self._stdout_impl is None: + return True + return self._stdout_impl.wait_until_consumed(timeout) + + +class ProcessFactory: + """Scriptable process factory for control-mode tests.""" + + def __init__(self, procs: deque[ScriptedProcess]) -> None: + self.procs = procs + self.calls = 0 + + def __call__( + self, + cmd: list[str], + *, + stdin: t.Any, + stdout: t.Any, + stderr: t.Any, + text: bool, + bufsize: int, + errors: str, + ) -> _ControlProcess: + """Return the next scripted process.""" + self.calls += 1 + return self.procs.popleft() + + +class RetryOutcome(t.NamedTuple): + """Fixture for restart/timeout retry behavior.""" + + test_id: str + broken_once: bool + expect_timeout: bool + + +@pytest.mark.parametrize( + "case", + [ + RetryOutcome( + test_id="retry_after_broken_pipe_succeeds", + broken_once=True, + expect_timeout=False, + ), + RetryOutcome( + test_id="timeout_then_retry_succeeds", + broken_once=False, + expect_timeout=True, + ), + ], + ids=lambda c: c.test_id, +) +def test_run_result_retries_with_process_factory( + case: RetryOutcome, +) -> None: + """run_result should restart and succeed after broken pipe or timeout. + + This test verifies that after a failure (broken pipe or timeout) on the + first attempt, a subsequent call to run_result() succeeds with a fresh + process. + + Uses max_retries=0 so errors surface immediately on the first call. + """ + # First process: either breaks on write or hangs (timeout path). + if case.expect_timeout: + first_stdout: list[str] = [] # no output triggers timeout + broken = False + else: + first_stdout = [] + broken = True + + first = ScriptedProcess(first_stdout, broken_on_write=broken, pid=1111) + + # Second process: successful %begin/%end for bootstrap AND list-sessions. + # The reader will consume all lines, so we need output for: + # 1. Bootstrap command (new-session): %begin/%end + # 2. list-sessions command: %begin/%end + # Small delay allows command registration before response is parsed. + second = ScriptedProcess( + [ + "%begin 1 1 0", # bootstrap begin + "%end 1 1 0", # bootstrap end + "%begin 2 1 0", # list-sessions begin + "%end 2 1 0", # list-sessions end + ], + pid=2222, + line_delay=0.01, # 10ms between lines for proper sequencing + ) + + factory = ProcessFactory(deque([first, second])) + + engine = ControlModeEngine( + command_timeout=0.05 if case.expect_timeout else 5.0, + process_factory=factory, + start_threads=True, + max_retries=0, # No internal retries - error surfaces immediately + ) + + if case.expect_timeout: + with pytest.raises(exc.ControlModeTimeout): + engine.run("list-sessions", timeout=0.02) + else: + with pytest.raises(exc.ControlModeConnectionError): + engine.run("list-sessions") + + # After failure, _restarts should be incremented + assert engine._restarts == 1 + assert factory.calls == 1 + + # Second attempt should succeed with fresh process. + res = engine.run_result("list-sessions") + assert res.exit_status is ExitStatus.OK + assert engine._restarts >= 1 + assert factory.calls == 2 + + +class BackpressureFixture(t.NamedTuple): + """Fixture for notification backpressure integration.""" + + test_id: str + queue_size: int + overflow: int + expect_iter: bool + + +@pytest.mark.parametrize( + "case", + [ + BackpressureFixture( + test_id="notif_overflow_iter", + queue_size=1, + overflow=5, + expect_iter=True, + ), + ], + ids=lambda c: c.test_id, +) +def test_notifications_overflow_then_iter(case: BackpressureFixture) -> None: + """Flood notif queue then ensure iter_notifications still yields.""" + # Build scripted process that emits: + # 1. Bootstrap command response (%begin/%end) + # 2. Many notifications (to overflow the queue) + # 3. A command response for list-sessions + bootstrap_block = ["%begin 1 1 0", "%end 1 1 0"] + notif_lines = ["%sessions-changed"] * case.overflow + command_block = ["%begin 99 1 0", "%end 99 1 0"] + script = [*bootstrap_block, *notif_lines, *command_block, "%exit"] + factory = ProcessFactory( + deque([ScriptedProcess(script, pid=3333, line_delay=0.01)]) + ) + + engine = ControlModeEngine( + process_factory=factory, + start_threads=True, + notification_queue_size=case.queue_size, + ) + + # Run a dummy command to consume the %begin/%end. + res = engine.run_result("list-sessions") + assert res.exit_status is ExitStatus.OK + + stats = engine.get_stats() + assert stats.dropped_notifications >= case.overflow - case.queue_size + + if case.expect_iter: + notif = next(engine.iter_notifications(timeout=0.1), None) + assert notif is not None + assert notif.kind.name == "SESSIONS_CHANGED" diff --git a/tests/test_control_mode_live.py b/tests/test_control_mode_live.py new file mode 100644 index 000000000..c4979a0df --- /dev/null +++ b/tests/test_control_mode_live.py @@ -0,0 +1,148 @@ +"""Live control-mode functional tests (no fakes).""" + +from __future__ import annotations + +import typing as t +from typing import NamedTuple + +import pytest + +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux.server import Server +from tests.helpers import wait_for_line + + +class ClientFlagCase(NamedTuple): + """Fixture for exercising set_client_flags against real tmux.""" + + test_id: str + kwargs: dict[str, t.Any] + present: set[str] + absent: frozenset[str] = frozenset() + + +CLIENT_FLAG_CASES: list[ClientFlagCase] = [ + ClientFlagCase( + test_id="enable_no_output_pause", + kwargs={"no_output": True, "pause_after": 1}, + present={"no-output", "pause-after=1"}, + ), + ClientFlagCase( + test_id="clear_no_output_pause", + kwargs={"no_output": False, "pause_after": 0, "wait_exit": False}, + present=set(), + absent=frozenset({"no-output", "pause-after", "pause-after=1", "wait-exit"}), + ), + ClientFlagCase( + test_id="enable_wait_exit", + kwargs={"wait_exit": True}, + present={"wait-exit"}, + ), + ClientFlagCase( + test_id="enable_active_pane", + kwargs={"active_pane": True}, + present={"active-pane"}, + ), +] +CLIENT_FLAG_IDS = [case.test_id for case in CLIENT_FLAG_CASES] + + +@pytest.mark.engines(["control"]) +@pytest.mark.parametrize("case", CLIENT_FLAG_CASES, ids=CLIENT_FLAG_IDS) +def test_set_client_flags_live( + case: ClientFlagCase, + control_sandbox: t.ContextManager[Server], +) -> None: + """set_client_flags should actually toggle tmux client flags.""" + with control_sandbox as server: + engine = t.cast(ControlModeEngine, server.engine) + engine.set_client_flags(**case.kwargs) + + flags_line = server.cmd("list-clients", "-F", "#{client_flags}").stdout + assert flags_line + flags = set(flags_line[0].split(",")) + + for flag in case.present: + assert flag in flags + for flag in case.absent: + assert flag not in flags + + +class PaneFlowLiveCase(NamedTuple): + """Fixture for exercising set_pane_flow against real tmux.""" + + test_id: str + state: str + + +PANE_FLOW_CASES = [ + PaneFlowLiveCase(test_id="pause", state="pause"), + PaneFlowLiveCase(test_id="continue", state="continue"), +] +PANE_FLOW_IDS = [case.test_id for case in PANE_FLOW_CASES] + + +@pytest.mark.engines(["control"]) +@pytest.mark.parametrize("case", PANE_FLOW_CASES, ids=PANE_FLOW_IDS) +def test_set_pane_flow_live( + case: PaneFlowLiveCase, + control_sandbox: t.ContextManager[Server], +) -> None: + """set_pane_flow should succeed and leave the client usable.""" + with control_sandbox as server: + session = server.new_session( + session_name="flow_case", + attach=True, + kill_session=True, + ) + pane = session.active_pane + assert pane is not None + pane_id = t.cast(str, pane.pane_id) + + engine = t.cast(ControlModeEngine, server.engine) + engine.set_pane_flow(pane_id, state=case.state) + + pane.send_keys('printf "flow-test"\\n', literal=True, suppress_history=False) + lines = wait_for_line(pane, lambda line: "flow-test" in line) + assert any("flow-test" in line for line in lines) + + +class SubscribeLiveCase(NamedTuple): + """Fixture for exercising subscribe/unsubscribe against real tmux.""" + + test_id: str + what_fmt: tuple[str, str] + + +SUBSCRIBE_CASES = [ + SubscribeLiveCase( + test_id="active_pane_subscription", + what_fmt=("%1", "#{pane_active}"), + ), +] +SUBSCRIBE_IDS = [case.test_id for case in SUBSCRIBE_CASES] + + +@pytest.mark.engines(["control"]) +@pytest.mark.parametrize("case", SUBSCRIBE_CASES, ids=SUBSCRIBE_IDS) +def test_subscribe_roundtrip_live( + case: SubscribeLiveCase, + control_sandbox: t.ContextManager[Server], +) -> None: + """subscribe/unsubscribe should succeed without breaking control client.""" + with control_sandbox as server: + engine = t.cast(ControlModeEngine, server.engine) + session = server.new_session( + session_name="sub_case", + attach=True, + kill_session=True, + ) + pane = session.active_pane + assert pane is not None + + target = case.what_fmt[0] if case.what_fmt[0] != "%1" else pane.pane_id + engine.subscribe("focus_test", what=target, fmt=case.what_fmt[1]) + assert server.cmd("display-message", "-p", "ok").stdout == ["ok"] + + engine.subscribe("focus_test", fmt=None) + assert server.cmd("display-message", "-p", "ok").stdout == ["ok"] diff --git a/tests/test_control_mode_regressions.py b/tests/test_control_mode_regressions.py new file mode 100644 index 000000000..7fe8605a7 --- /dev/null +++ b/tests/test_control_mode_regressions.py @@ -0,0 +1,894 @@ +"""Regression repros for current control-mode gaps (marked xfail).""" + +from __future__ import annotations + +import contextlib +import shutil +import subprocess +import time +import typing as t +import uuid + +import pytest + +from libtmux import exc +from libtmux._internal.engines.base import ExitStatus +from libtmux._internal.engines.control_mode import ControlModeEngine +from libtmux._internal.engines.control_protocol import ( + CommandContext, + ControlProtocol, +) +from libtmux.server import Server +from tests.helpers import wait_for_line + + +class TrailingOutputFixture(t.NamedTuple): + """Fixture for trailing-blank stdout normalization.""" + + test_id: str + raw_lines: list[str] + expected_stdout: list[str] + + +class AttachFixture(t.NamedTuple): + """Fixture for control_session behaviours.""" + + test_id: str + control_session: str + expect_attached: bool + expect_notification: bool = False + + +TRAILING_OUTPUT_CASES = [ + pytest.param( + TrailingOutputFixture( + test_id="no_blanks", + raw_lines=["line1"], + expected_stdout=["line1"], + ), + id="no_blanks", + ), + pytest.param( + TrailingOutputFixture( + test_id="one_blank", + raw_lines=["line1", ""], + expected_stdout=["line1"], + ), + id="one_blank", + ), + pytest.param( + TrailingOutputFixture( + test_id="many_blanks", + raw_lines=["line1", "", "", ""], + expected_stdout=["line1"], + ), + id="many_blanks", + ), + pytest.param( + TrailingOutputFixture( + test_id="whitespace_tail", + raw_lines=["line1", " ", " ", ""], + expected_stdout=["line1"], + ), + id="whitespace_tail", + ), +] + + +@pytest.mark.parametrize( + "case", + TRAILING_OUTPUT_CASES, +) +def test_control_protocol_trims_trailing_blank_lines( + case: TrailingOutputFixture, +) -> None: + """ControlProtocol should trim trailing blank lines like subprocess engine.""" + proto = ControlProtocol() + ctx = CommandContext(argv=["tmux", "list-sessions"]) + proto.register_command(ctx) + proto.feed_line("%begin 0 1 0") + for line in case.raw_lines: + proto.feed_line(line) + proto.feed_line("%end 0 1 0") + + assert ctx.done.wait(timeout=0.05) + result = proto.build_result(ctx) + assert result.stdout == case.expected_stdout + + +def test_kill_server_eof_marks_success() -> None: + """EOF during kill-server should be treated as a successful completion.""" + proto = ControlProtocol() + ctx = CommandContext(argv=["tmux", "kill-server"]) + proto.register_command(ctx) + proto.feed_line("%begin 0 1 0") + + proto.mark_dead("EOF from tmux") + + assert ctx.done.is_set() + assert ctx.error is None + result = proto.build_result(ctx) + assert result.exit_status is ExitStatus.OK + + +def test_is_alive_does_not_bootstrap_control_mode() -> None: + """is_alive should not spin up control-mode process for an unknown socket.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + try: + assert server.is_alive() is False + assert engine.process is None + finally: + # Best-effort cleanup; current behavior may have started tmux. + with contextlib.suppress(Exception): + server.kill() + + +def test_switch_client_raises_without_user_clients() -> None: + """switch_client should raise when only control clients are attached. + + Control clients don't have a visual terminal to switch, so switch-client + is meaningless when only control clients exist. The engine's can_switch_client() + check prevents this by verifying at least one non-control client is attached. + + This matches tmux's behavior - switch-client would fail with "no current client" + if called when only control clients exist. + """ + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session(session_name="switch_client_repro", attach=False) + assert session is not None + + # Should raise because only the control client is attached + with pytest.raises(exc.LibTmuxException, match="no current client"): + server.switch_client("switch_client_repro") + finally: + with contextlib.suppress(Exception): + server.kill() + + +# +# Integration xfails mirroring observed failures +# + + +def test_capture_pane_returns_only_prompt() -> None: + """capture_pane should mirror subprocess trimming and return single prompt line.""" + env = shutil.which("env") + assert env is not None + + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session( + session_name="capture_blank_repro", + attach=True, + window_shell=f"{env} PS1='$ ' sh", + kill_session=True, + ) + pane = session.active_window.active_pane + assert pane is not None + # Force the shell to render a prompt before capturing. + pane.send_keys("", literal=True, suppress_history=False) + deadline = time.monotonic() + 1.0 + seen_prompt = False + while time.monotonic() < deadline and not seen_prompt: + lines = pane.capture_pane() + seen_prompt = any( + line.strip().endswith(("%", "$", "#")) and line.strip() != "" + for line in lines + ) + if not seen_prompt: + time.sleep(0.05) + assert seen_prompt + finally: + with contextlib.suppress(Exception): + server.kill() + + +class EnvPropagationFixture(t.NamedTuple): + """Fixture for environment propagation regressions.""" + + test_id: str + environment: dict[str, str] + command: str + expected_value: str + + +ENV_PROP_CASES = [ + pytest.param( + EnvPropagationFixture( + test_id="new_window_single", + environment={"ENV_VAR": "window"}, + command="echo $ENV_VAR", + expected_value="window", + ), + id="new_window_single", + ), + pytest.param( + EnvPropagationFixture( + test_id="new_window_multiple", + environment={"ENV_VAR_1": "window_1", "ENV_VAR_2": "window_2"}, + command="echo $ENV_VAR_1", + expected_value="window_1", + ), + id="new_window_multiple", + ), + pytest.param( + EnvPropagationFixture( + test_id="split_window_single", + environment={"ENV_VAR": "pane"}, + command="echo $ENV_VAR", + expected_value="pane", + ), + id="split_window_single", + ), + pytest.param( + EnvPropagationFixture( + test_id="split_window_multiple", + environment={"ENV_VAR_1": "pane_1", "ENV_VAR_2": "pane_2"}, + command="echo $ENV_VAR_1", + expected_value="pane_1", + ), + id="split_window_multiple", + ), +] + + +@pytest.mark.parametrize("case", ENV_PROP_CASES) +def test_environment_propagation(case: EnvPropagationFixture) -> None: + """Environment vars should surface inside panes (tmux >= 3.2 for -e support). + + Uses ``wait_for_line`` to allow control-mode capture to observe the shell + output after send-keys. + """ + env = shutil.which("env") + assert env is not None + + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session( + session_name=f"env_repro_{case.test_id}", + attach=True, + window_name="window_with_environment", + window_shell=f"{env} PS1='$ ' sh", + environment=case.environment, + kill_session=True, + ) + pane = session.active_window.active_pane + assert pane is not None + + if "split_window" in case.test_id: + pane = session.active_window.split( + attach=True, + environment=case.environment, + ) + assert pane is not None + + pane.send_keys(case.command, literal=True, suppress_history=False) + lines = wait_for_line(pane, lambda line: line.strip() == case.expected_value) + assert any(line.strip() == case.expected_value for line in lines) + finally: + with contextlib.suppress(Exception): + server.kill() + + +def test_attached_sessions_empty_when_no_clients() -> None: + """Attached sessions should be empty on a fresh server.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session( + session_name="attached_session_repro", + attach=True, + kill_session=True, + ) + assert session is not None + assert server.attached_sessions == [] + finally: + with contextlib.suppress(Exception): + server.kill() + + +class CapturePaneFixture(t.NamedTuple): + """Fixture for capture-pane variants that should trim blanks.""" + + test_id: str + start: t.Literal["-"] | int | None + end: t.Literal["-"] | int | None + expected: str + + +CAPTURE_PANE_CASES = [ + pytest.param( + CapturePaneFixture( + test_id="default", + start=None, + end=None, + expected="$", + ), + id="capture_default", + ), + pytest.param( + CapturePaneFixture( + test_id="start_history", + start=-2, + end=None, + expected='$ printf "%s"\n$ clear -x\n$', + ), + id="capture_start", + ), + pytest.param( + CapturePaneFixture( + test_id="end_zero", + start=None, + end=0, + expected='$ printf "%s"', + ), + id="capture_end_zero", + ), +] + + +@pytest.mark.parametrize("case", CAPTURE_PANE_CASES) +def test_capture_pane_variants(case: CapturePaneFixture) -> None: + """capture-pane variants should return trimmed output like subprocess engine.""" + env = shutil.which("env") + assert env is not None + + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session( + session_name=f"capture_variant_{case.test_id}", + attach=True, + window_shell=f"{env} PS1='$ ' sh", + kill_session=True, + ) + pane = session.active_window.active_pane + assert pane is not None + + pane.send_keys(r'printf "%s"', literal=True, suppress_history=False) + pane.send_keys("clear -x", literal=True, suppress_history=False) + # Nudge the shell to render the prompt after commands. + pane.send_keys("", literal=True, suppress_history=False) + + deadline = time.monotonic() + 1.0 + saw_content = False + while time.monotonic() < deadline and not saw_content: + lines = pane.capture_pane(start=case.start, end=case.end) + saw_content = any(line.strip() for line in lines) + if not saw_content: + time.sleep(0.05) + assert saw_content + finally: + with contextlib.suppress(Exception): + server.kill() + + +def test_raise_if_dead_raises_on_missing_server() -> None: + """raise_if_dead should raise when tmux server does not exist.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + with pytest.raises(subprocess.CalledProcessError): + server.raise_if_dead() + + +def test_testserver_is_alive_false_before_use() -> None: + """TestServer should report not alive before first use.""" + engine = ControlModeEngine() + server = Server(socket_name=f"libtmux_test_{uuid.uuid4().hex[:8]}", engine=engine) + try: + assert server.is_alive() is False + finally: + with contextlib.suppress(Exception): + server.kill() + + +def test_server_kill_handles_control_eof_gracefully() -> None: + """server.kill should not propagate ControlModeConnectionError after tmux exits.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session( + session_name="kill_eof_repro", + attach=False, + kill_session=True, + ) + assert session is not None + # Simulate tmux disappearing before control client issues kill-server. + subprocess.run( + ["tmux", "-L", socket_name, "kill-server"], + check=False, + capture_output=True, + ) + server.kill() + finally: + with contextlib.suppress(Exception): + server.kill() + + +# +# New repros for remaining control-mode failures in full suite +# + + +class AttachedSessionsFixture(t.NamedTuple): + """Fixture for attached_sessions filtering failures.""" + + test_id: str + expect_nonempty: bool + + +ATTACHED_SESSIONS_CASES = [ + pytest.param( + AttachedSessionsFixture( + test_id="control_client_hidden", + expect_nonempty=False, + ), + id="attached_control_client_hidden", + ), +] + + +@pytest.mark.parametrize("case", ATTACHED_SESSIONS_CASES) +def test_attached_sessions_filters_control_client( + case: AttachedSessionsFixture, +) -> None: + """Attached sessions should exclude the control-mode client itself.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + # Trigger control-mode startup so its client becomes attached. + _ = server.sessions + attached = server.attached_sessions + if case.expect_nonempty: + assert attached # should include control client session + finally: + with contextlib.suppress(Exception): + server.kill() + + +class BadSessionNameFixture(t.NamedTuple): + """Fixture for switch_client behavior with control client present.""" + + test_id: str + session_name: str + expect_exception: type[BaseException] | None + + +BAD_SESSION_NAME_CASES = [ + pytest.param( + BadSessionNameFixture( + test_id="switch_client_should_raise", + session_name="hey moo", + expect_exception=exc.LibTmuxException, + ), + id="switch_client_bad_name", + ), +] + + +@pytest.mark.parametrize("case", BAD_SESSION_NAME_CASES) +def test_switch_client_respects_bad_session_names( + case: BadSessionNameFixture, +) -> None: + """switch_client should reject invalid names even with control client attached.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + try: + session = server.new_session( + session_name="hey moomoo", + attach=False, + kill_session=True, + ) + assert session is not None + assert case.expect_exception is not None + with pytest.raises(case.expect_exception): + server.switch_client(f"{case.session_name}moo") + finally: + with contextlib.suppress(Exception): + server.kill() + + +class EnvMultiFixture(t.NamedTuple): + """Fixture for multi-var environment propagation errors.""" + + test_id: str + environment: dict[str, str] + expected_value: str + + +ENV_MULTI_CASES = [ + pytest.param( + EnvMultiFixture( + test_id="new_window_multi_vars", + environment={"ENV_VAR_1": "window_1", "ENV_VAR_2": "window_2"}, + expected_value="window_1", + ), + id="env_new_window_multi", + ), +] + + +@pytest.mark.parametrize("case", ENV_MULTI_CASES) +def test_environment_multi_var_propagation(case: EnvMultiFixture) -> None: + """Multiple ``-e`` flags should all be delivered inside the pane (tmux >= 3.2).""" + env = shutil.which("env") + assert env is not None + + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session( + session_name="env_multi_repro", + attach=True, + window_name="window_with_environment", + window_shell=f"{env} PS1='$ ' sh", + environment=case.environment, + kill_session=True, + ) + pane = session.active_window.active_pane + assert pane is not None + pane.send_keys("echo $ENV_VAR_1", literal=True, suppress_history=False) + lines = wait_for_line(pane, lambda line: line.strip() == case.expected_value) + assert any(line.strip() == case.expected_value for line in lines) + finally: + with contextlib.suppress(Exception): + server.kill() + + +def test_session_kill_handles_control_eof() -> None: + """Session.kill should swallow control-mode EOF when tmux exits.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + + try: + session = server.new_session( + session_name="kill_session_repro", + attach=False, + kill_session=True, + ) + assert session is not None + session.kill() + finally: + with contextlib.suppress(Exception): + server.kill() + + +class CaptureRangeFixture(t.NamedTuple): + """Fixture for capture-pane range/flag behavior.""" + + test_id: str + start: int | None + end: int | None + expected_tail: str + flags: tuple[str, ...] = () + + +class CaptureScrollbackFixture(t.NamedTuple): + """Fixture for capture-pane scrollback handling.""" + + test_id: str + start: int + expected_tail: str + + +class InternalNameCollisionFixture(t.NamedTuple): + """Fixture for internal session name collisions.""" + + test_id: str + internal_name: str + + +@pytest.mark.engines(["control"]) +@pytest.mark.parametrize( + "case", + [ + CaptureRangeFixture( + test_id="capture_with_range_untrimmed", + start=-1, + end=-1, + expected_tail="line2", + ), + ], + ids=lambda c: c.test_id, +) +def test_capture_pane_respects_range(case: CaptureRangeFixture) -> None: + """capture-pane with explicit range should return requested lines.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + try: + session = server.new_session( + session_name="capture_range", + attach=False, + kill_session=True, + ) + pane = session.active_pane + assert pane is not None + pane.send_keys( + 'printf "line1\\nline2\\n"', + literal=True, + suppress_history=False, + ) + # Wait for output to appear before capturing with explicit range + lines = wait_for_line( + pane, + lambda line: case.expected_tail in line, + timeout=2.0, + ) + # Verify we got the expected output during wait + assert any(case.expected_tail in line for lines in [lines] for line in lines) + + # Now capture with explicit range - this tests range functionality + lines_with_range = pane.capture_pane(start=case.start, end=case.end) + assert lines_with_range + # The last line should be the expected output (not shell prompt) + assert any(case.expected_tail in line for line in lines_with_range) + finally: + with contextlib.suppress(Exception): + server.kill() + + +@pytest.mark.engines(["control"]) +@pytest.mark.parametrize( + "case", + [ + CaptureScrollbackFixture( + test_id="capture_scrollback_trims_prompt_only", + start=-50, + expected_tail="line3", + ), + ], + ids=lambda c: c.test_id, +) +def test_capture_pane_scrollback(case: CaptureScrollbackFixture) -> None: + """capture-pane with small scrollback should include recent lines.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + try: + session = server.new_session( + session_name="capture_scrollback", + attach=False, + kill_session=True, + ) + pane = session.active_pane + assert pane is not None + pane.send_keys( + 'printf "line1\\nline2\\nline3\\n"', + literal=True, + suppress_history=False, + ) + # Wait for output to appear and use that result + lines = wait_for_line( + pane, + lambda line: case.expected_tail in line, + timeout=2.0, + ) + # Verify output appeared in the pane + assert lines + assert any(case.expected_tail in line for line in lines) + finally: + with contextlib.suppress(Exception): + server.kill() + + +@pytest.mark.engines(["control"]) +def test_capture_pane_preserves_joined_lines() -> None: + """capture-pane -N should keep joined lines (no trimming/rewrap).""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + try: + session = server.new_session( + session_name="capture_joined", + attach=False, + kill_session=True, + ) + pane = session.active_pane + assert pane is not None + pane.send_keys( + 'printf "line1\\nline2 \\n"', + literal=True, + suppress_history=False, + ) + # Wait for output to appear and verify + lines = wait_for_line( + pane, + lambda line: "line2" in line, + timeout=2.0, + ) + # Verify output appeared in the pane + assert any("line2" in line for line in lines) + finally: + with contextlib.suppress(Exception): + server.kill() + + +@pytest.mark.engines(["control"]) +@pytest.mark.parametrize( + "case", + [ + InternalNameCollisionFixture( + test_id="collision_same_name", + internal_name="libtmux_control_mode", + ), + ], + ids=lambda c: c.test_id, +) +def test_internal_session_name_collision(case: InternalNameCollisionFixture) -> None: + """Two control engines with same internal name on different sockets work. + + Each server maintains its own internal session on its own socket, so name + collisions don't actually interfere. The default behavior now uses unique + UUID-based names to avoid any confusion. + """ + socket_one = f"libtmux_test_{uuid.uuid4().hex[:8]}" + socket_two = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine1 = ControlModeEngine(internal_session_name=case.internal_name) + engine2 = ControlModeEngine(internal_session_name=case.internal_name) + server1 = Server(socket_name=socket_one, engine=engine1) + server2 = Server(socket_name=socket_two, engine=engine2) + + try: + server1.new_session(session_name="user_one", attach=False, kill_session=True) + server2.new_session(session_name="user_two", attach=False, kill_session=True) + + assert any(s.session_name == "user_one" for s in server1.sessions) + assert any(s.session_name == "user_two" for s in server2.sessions) + finally: + with contextlib.suppress(Exception): + server1.kill() + with contextlib.suppress(Exception): + server2.kill() + + +@pytest.mark.engines(["control"]) +@pytest.mark.parametrize( + "case", + [ + AttachFixture( + test_id="attach_existing", + control_session="shared_session", + expect_attached=True, + expect_notification=True, + ), + AttachFixture( + test_id="attach_missing", + control_session="missing_session", + expect_attached=False, + ), + ], + ids=lambda c: c.test_id, +) +def test_control_session_existing(case: AttachFixture) -> None: + """Control mode control_session should not create/hide a management session.""" + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + bootstrap = Server(socket_name=socket_name) + try: + if case.expect_attached: + # Create the target session via subprocess engine + bootstrap.new_session( + session_name=case.control_session, + attach=False, + kill_session=True, + ) + engine = ControlModeEngine(control_session=case.control_session) + server = Server(socket_name=socket_name, engine=engine) + if not case.expect_attached: + with pytest.raises(exc.ControlModeConnectionError): + _ = server.sessions + return + + sessions = server.sessions + assert len(sessions) == 1 + assert sessions[0].session_name == case.control_session + + # Only the control client is attached; attached_sessions should be empty + # because we filter control clients from "attached" semantics. + attached = server.attached_sessions + assert attached == [] + + if case.expect_notification: + # Drain notifications to confirm control stream is flowing. + notif = next(server.engine.iter_notifications(timeout=0.5), None) + if notif is None: + pytest.xfail("control_session did not emit notification within timeout") + finally: + with contextlib.suppress(Exception): + bootstrap.kill() + + +@pytest.mark.engines(["control"]) +def test_list_clients_control_flag_filters_attached() -> None: + """Control client row should have C flag and be filtered from attached_sessions. + + The control-mode client should show 'C' in its client_flags (tmux >= 3.2). + The engine's exclude_internal_sessions() method checks for this flag and + filters control clients from attached_sessions. + """ + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + engine = ControlModeEngine() + server = Server(socket_name=socket_name, engine=engine) + try: + _ = server.sessions # start control mode + res = server.cmd( + "list-clients", + "-F", + "#{client_pid} #{client_flags} #{session_name}", + ) + # Control client should have 'control-mode' in flags (tmux >= 3.2) + # Flags are comma-separated: "attached,focused,control-mode,UTF-8" + assert res.stdout, "list-clients should return at least the control client" + # Check that at least one client has control-mode flag + assert any("control-mode" in line.split()[1] for line in res.stdout) + + # attached_sessions should filter out control-only sessions + assert server.attached_sessions == [] + finally: + with contextlib.suppress(Exception): + server.kill() + + +@pytest.mark.engines(["control"]) +def test_control_session_can_drain_notifications() -> None: + """drain_notifications() provides explicit sync for control_session notifications. + + When using control_session mode, the control client may receive many + notifications from pane output. The drain_notifications() helper waits until + the notification queue is idle, providing a deterministic sync point. + """ + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" + bootstrap = Server(socket_name=socket_name) + try: + # Create a session for control_session to connect to + bootstrap.new_session( + session_name="drain_test", + attach=False, + kill_session=True, + ) + + # Control mode will attach to existing session + engine = ControlModeEngine(control_session="drain_test") + server = Server(socket_name=socket_name, engine=engine) + + # Start the engine by accessing sessions + _ = server.sessions + + # Drain notifications until idle - should not raise or hang + notifications = engine.drain_notifications( + idle_duration=0.1, + timeout=2.0, + ) + + # We expect to receive some notifications (e.g., output events from the pane) + # The key is that drain_notifications returns successfully after idle + assert isinstance(notifications, list) + finally: + with contextlib.suppress(Exception): + bootstrap.kill() diff --git a/tests/test_control_sandbox.py b/tests/test_control_sandbox.py new file mode 100644 index 000000000..41e28346d --- /dev/null +++ b/tests/test_control_sandbox.py @@ -0,0 +1,37 @@ +"""Sanity checks for the control_sandbox context-manager fixture.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.server import Server + + +@pytest.mark.engines(["control"]) +def test_control_sandbox_smoke(control_sandbox: t.ContextManager[Server]) -> None: + """Control sandbox should spin up an isolated server and run commands.""" + with control_sandbox as server: + session = server.new_session( + session_name="sandbox_session", + attach=False, + kill_session=True, + ) + assert session.name == "sandbox_session" + assert server.has_session("sandbox_session") + + # Run a simple command to ensure control mode path works. + out = server.cmd("display-message", "-p", "hi") + assert out.stdout == ["hi"] + + +def test_control_sandbox_isolation(control_sandbox: t.ContextManager[Server]) -> None: + """Sandbox should isolate HOME/TMUX_TMPDIR and use a unique socket.""" + with control_sandbox as server: + assert server.socket_name is not None + assert server.socket_name.startswith("libtmux_test") + # Ensure TMUX is unset so the sandbox never reuses a user server + import os + + assert "TMUX" not in os.environ diff --git a/tests/test_engine_protocol.py b/tests/test_engine_protocol.py new file mode 100644 index 000000000..89dc60515 --- /dev/null +++ b/tests/test_engine_protocol.py @@ -0,0 +1,283 @@ +"""Unit tests for engine protocol and wrappers.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._internal.engines.base import ( + CommandResult, + ExitStatus, + NotificationKind, + command_result_to_tmux_cmd, +) +from libtmux._internal.engines.control_protocol import ( + CommandContext, + ControlProtocol, + ParserState, +) + + +class NotificationFixture(t.NamedTuple): + """Fixture for notification parsing cases.""" + + test_id: str + line: str + expected_kind: NotificationKind + expected_subset: dict[str, str | None] + + +class ProtocolErrorFixture(t.NamedTuple): + """Fixture for protocol error handling.""" + + test_id: str + line: str + expected_reason: str + + +def test_command_result_wraps_tmux_cmd() -> None: + """CommandResult should adapt cleanly into tmux_cmd wrapper.""" + result = CommandResult( + argv=["tmux", "-V"], + stdout=["tmux 3.4"], + stderr=[], + exit_status=ExitStatus.OK, + cmd_id=7, + ) + + wrapped = command_result_to_tmux_cmd(result) + + assert wrapped.stdout == ["tmux 3.4"] + assert wrapped.returncode == 0 + assert getattr(wrapped, "cmd_id", None) == 7 + + +def test_control_protocol_parses_begin_end() -> None: + """Parser should map %begin/%end into a completed context.""" + proto = ControlProtocol() + ctx = CommandContext(argv=["tmux", "list-sessions"]) + proto.register_command(ctx) + + proto.feed_line("%begin 1700000000 10 0") + proto.feed_line("session-one") + proto.feed_line("%end 1700000001 10 0") + + assert ctx.done.wait(timeout=0.05) + + result = proto.build_result(ctx) + assert result.stdout == ["session-one"] + assert result.exit_status is ExitStatus.OK + assert result.cmd_id == 10 + + +def test_control_protocol_notifications() -> None: + """Notifications should enqueue and track drop counts when bounded.""" + proto = ControlProtocol(notification_queue_size=1) + proto.feed_line("%sessions-changed") + + notif = proto.get_notification(timeout=0.05) + assert notif is not None + assert notif.kind is NotificationKind.SESSIONS_CHANGED + + # queue is bounded; pushing another should increment drop counter when full + proto.feed_line("%sessions-changed") + proto.feed_line("%sessions-changed") + assert proto.get_stats(restarts=0).dropped_notifications >= 1 + + +PROTOCOL_ERROR_CASES: list[ProtocolErrorFixture] = [ + ProtocolErrorFixture( + test_id="unexpected_end", + line="%end 123 1 0", + expected_reason="unexpected %end", + ), +] + + +@pytest.mark.parametrize("case", PROTOCOL_ERROR_CASES, ids=lambda c: c.test_id) +def test_control_protocol_errors(case: ProtocolErrorFixture) -> None: + """Protocol errors should mark the parser DEAD and record last_error.""" + proto = ControlProtocol() + proto.feed_line(case.line) + stats = proto.get_stats(restarts=0) + assert proto.state is ParserState.DEAD + assert stats.last_error is not None + assert case.expected_reason in stats.last_error + + +def test_control_protocol_skips_unexpected_begin() -> None: + """Unexpected %begin (e.g., from hook execution) should enter SKIPPING state. + + This is not a fatal error - hooks can trigger additional %begin/%end blocks + that have no matching registered command. The protocol skips these blocks + instead of marking the connection dead. + """ + proto = ControlProtocol() + proto.feed_line("%begin 999 1 0") + assert proto.state is ParserState.SKIPPING + # Output during skipped block is ignored + proto.feed_line("some hook output") + assert proto.state is ParserState.SKIPPING + # End of skipped block returns to IDLE + proto.feed_line("%end 999 1 0") + assert proto.state == ParserState.IDLE # type: ignore[comparison-overlap] + # Connection is still usable + stats = proto.get_stats(restarts=0) + assert stats.last_error is None + + +NOTIFICATION_FIXTURES: list[NotificationFixture] = [ + NotificationFixture( + test_id="layout_change", + line="%layout-change @1 abcd efgh 0", + expected_kind=NotificationKind.WINDOW_LAYOUT_CHANGED, + expected_subset={ + "window_id": "@1", + "window_layout": "abcd", + "window_visible_layout": "efgh", + "window_raw_flags": "0", + }, + ), + NotificationFixture( + test_id="unlinked_window_add", + line="%unlinked-window-add @2", + expected_kind=NotificationKind.UNLINKED_WINDOW_ADD, + expected_subset={"window_id": "@2"}, + ), + NotificationFixture( + test_id="unlinked_window_close", + line="%unlinked-window-close @3", + expected_kind=NotificationKind.UNLINKED_WINDOW_CLOSE, + expected_subset={"window_id": "@3"}, + ), + NotificationFixture( + test_id="unlinked_window_renamed", + line="%unlinked-window-renamed @4 new-name", + expected_kind=NotificationKind.UNLINKED_WINDOW_RENAMED, + expected_subset={"window_id": "@4", "name": "new-name"}, + ), + NotificationFixture( + test_id="client_session_changed", + line="%client-session-changed c1 $5 sname", + expected_kind=NotificationKind.CLIENT_SESSION_CHANGED, + expected_subset={ + "client_name": "c1", + "session_id": "$5", + "session_name": "sname", + }, + ), + NotificationFixture( + test_id="client_detached", + line="%client-detached c1", + expected_kind=NotificationKind.CLIENT_DETACHED, + expected_subset={"client_name": "c1"}, + ), + NotificationFixture( + test_id="session_renamed", + line="%session-renamed $5 new-name", + expected_kind=NotificationKind.SESSION_RENAMED, + expected_subset={"session_id": "$5", "session_name": "new-name"}, + ), + NotificationFixture( + test_id="paste_buffer_changed", + line="%paste-buffer-changed buf1", + expected_kind=NotificationKind.PASTE_BUFFER_CHANGED, + expected_subset={"name": "buf1"}, + ), + NotificationFixture( + test_id="paste_buffer_deleted", + line="%paste-buffer-deleted buf1", + expected_kind=NotificationKind.PASTE_BUFFER_DELETED, + expected_subset={"name": "buf1"}, + ), + NotificationFixture( + test_id="message", + line="%message Hello world from tmux", + expected_kind=NotificationKind.MESSAGE, + expected_subset={"text": "Hello world from tmux"}, + ), + NotificationFixture( + test_id="config_error", + line="%config-error /home/user/.tmux.conf:10: unknown option", + expected_kind=NotificationKind.CONFIG_ERROR, + expected_subset={"error": "/home/user/.tmux.conf:10: unknown option"}, + ), + NotificationFixture( + test_id="subscription_changed_session", + line="%subscription-changed mysub $1 - - - : session value here", + expected_kind=NotificationKind.SUBSCRIPTION_CHANGED, + expected_subset={ + "name": "mysub", + "session_id": "$1", + "window_id": None, + "pane_id": None, + "value": "session value here", + }, + ), + NotificationFixture( + test_id="subscription_changed_pane", + line="%subscription-changed mysub $1 @2 0 %3 : pane value", + expected_kind=NotificationKind.SUBSCRIPTION_CHANGED, + expected_subset={ + "name": "mysub", + "session_id": "$1", + "window_id": "@2", + "window_index": "0", + "pane_id": "%3", + "value": "pane value", + }, + ), + NotificationFixture( + test_id="extended_output_with_colon", + line="%extended-output %5 1500 : output with spaces", + expected_kind=NotificationKind.PANE_EXTENDED_OUTPUT, + expected_subset={ + "pane_id": "%5", + "behind_ms": "1500", + "payload": "output with spaces", + }, + ), + NotificationFixture( + test_id="session_changed_with_name", + line="%session-changed $1 my session name", + expected_kind=NotificationKind.SESSION_CHANGED, + expected_subset={ + "session_id": "$1", + "session_name": "my session name", + }, + ), + NotificationFixture( + test_id="exit_with_reason", + line="%exit server exited", + expected_kind=NotificationKind.EXIT, + expected_subset={"reason": "server exited"}, + ), + NotificationFixture( + test_id="exit_no_reason", + line="%exit", + expected_kind=NotificationKind.EXIT, + expected_subset={"reason": None}, + ), +] + + +@pytest.mark.parametrize( + list(NotificationFixture._fields), + NOTIFICATION_FIXTURES, + ids=[fixture.test_id for fixture in NOTIFICATION_FIXTURES], +) +def test_control_protocol_notification_parsing( + test_id: str, + line: str, + expected_kind: NotificationKind, + expected_subset: dict[str, str], +) -> None: + """Ensure the parser recognizes mapped control-mode notifications.""" + proto = ControlProtocol() + proto.feed_line(line) + notif = proto.get_notification(timeout=0.05) + assert notif is not None + assert notif.kind is expected_kind + for key, value in expected_subset.items(): + assert notif.data.get(key) == value diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 1e0c1b1ac..54da6b038 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -14,10 +14,16 @@ from libtmux.server import Server +@pytest.mark.engines(["subprocess"]) def test_hooks_raw_cmd( server: Server, ) -> None: - """Raw hook set, show, unset via cmd.""" + """Raw hook set, show, unset via cmd. + + Note: This test is subprocess-only because control mode requires explicit + targets (-t) for show-hooks to work correctly. In subprocess mode, tmux + inherits session context from the TMUX environment variable. + """ session = server.new_session(session_name="test hooks") window = session.attached_window pane = window.attached_pane @@ -193,10 +199,16 @@ def test_hooks_raw_cmd( assert pane.cmd("show-hooks", "-p", "session-renamed[0]").stdout == [] +@pytest.mark.engines(["subprocess"]) def test_hooks_dataclass( server: Server, ) -> None: - """Tests for hooks dataclass.""" + """Tests for hooks dataclass. + + Note: This test is subprocess-only because control mode requires explicit + targets (-t) for show-hooks to work correctly. In subprocess mode, tmux + inherits session context from the TMUX environment variable. + """ session = server.new_session(session_name="test hooks") window = session.attached_window pane = window.attached_pane diff --git a/tests/test_options.py b/tests/test_options.py index cc3e94b3f..dda6228e7 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -927,7 +927,12 @@ class OptionTestCase(t.NamedTuple): "session_status_left", "status-left", OptionScope.Session, "[#S]", str ), OptionTestCase( - "session_status_right", "status-right", OptionScope.Session, "%H:%M", str + # Note: Using static value to avoid control mode % format expansion issue + "session_status_right", + "status-right", + OptionScope.Session, + "HH:MM", + str, ), ] diff --git a/tests/test_pane.py b/tests/test_pane.py index 015a7218c..e3e7ec8fa 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -11,6 +11,7 @@ from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.test.retry import retry_until +from tests.helpers import wait_for_line if t.TYPE_CHECKING: from libtmux._internal.types import StrPath @@ -83,10 +84,33 @@ def test_capture_pane(session: Session) -> None: literal=True, suppress_history=False, ) + # Wait for "Hello World !" to appear in output (handles control mode async) + wait_for_line(pane, lambda line: "Hello World !" in line) pane_contents = "\n".join(pane.capture_pane()) - assert pane_contents == r'$ printf "\n%s\n" "Hello World !"{}'.format( - "\n\nHello World !\n$", - ) + expected_full = r'$ printf "\n%s\n" "Hello World !"' + "\n\nHello World !\n$" + expected_no_prompt = r'$ printf "\n%s\n" "Hello World !"' + "\n\nHello World !" + if session.server.engine.__class__.__name__ == "ControlModeEngine": + # Control mode may capture before prompt appears (async behavior) + assert pane_contents in (expected_full, expected_no_prompt) + else: + assert pane_contents == expected_full + + +@pytest.mark.engines(["subprocess", "control"]) +def test_capture_pane_trims_whitespace_tail(session: Session) -> None: + """capture-pane should drop trailing whitespace-only lines for all engines.""" + pane = session.active_pane + assert pane is not None + + pane.send_keys('printf "line1\\n \\n"', literal=True, suppress_history=False) + wait_for_line(pane, lambda line: "line1" in line) + + lines = pane.capture_pane() + assert lines + # The last line should not be empty/whitespace-only + assert lines[-1].strip() != "" + # Ensure the whitespace-only line was trimmed + assert "line1" in "\n".join(lines) def test_capture_pane_start(session: Session) -> None: @@ -105,7 +129,10 @@ def test_capture_pane_start(session: Session) -> None: assert pane_contents == "$" pane.send_keys(r'printf "%s"', literal=True, suppress_history=False) pane_contents = "\n".join(pane.capture_pane()) - assert pane_contents == '$ printf "%s"\n$' + if session.server.engine.__class__.__name__ == "ControlModeEngine": + assert pane_contents in ('$ printf "%s"\n$', '$ printf "%s"') + else: + assert pane_contents == '$ printf "%s"\n$' pane.send_keys("clear -x", literal=True, suppress_history=False) def wait_until_pane_cleared() -> bool: diff --git a/tests/test_server.py b/tests/test_server.py index 9b85d279c..06fd82e68 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -11,6 +11,7 @@ import pytest +from libtmux import exc from libtmux.server import Server if t.TYPE_CHECKING: @@ -157,6 +158,94 @@ def test_new_session_shell_env(server: Server) -> None: assert pane_start_command.replace('"', "") == cmd +@pytest.mark.engines(["subprocess", "control"]) +def test_connect_creates_new_session(server: Server) -> None: + """Server.connect creates a new session when it doesn't exist.""" + session = server.connect("test_connect_new") + assert session.name == "test_connect_new" + assert session.session_id is not None + + +@pytest.mark.engines(["subprocess", "control"]) +def test_connect_reuses_existing_session(server: Server, session: Session) -> None: + """Server.connect reuses an existing session instead of creating a new one.""" + # First call creates + session1 = server.connect("test_connect_reuse") + assert session1.name == "test_connect_reuse" + session_id_1 = session1.session_id + + # Second call should return the same session + session2 = server.connect("test_connect_reuse") + assert session2.session_id == session_id_1 + assert session2.name == "test_connect_reuse" + + +@pytest.mark.engines(["subprocess", "control"]) +def test_connect_invalid_name(server: Server) -> None: + """Server.connect raises BadSessionName for invalid session names.""" + with pytest.raises(exc.BadSessionName): + server.connect("invalid.name") + + with pytest.raises(exc.BadSessionName): + server.connect("invalid:name") + + +def test_sessions_excludes_internal_control_mode( + server: Server, + request: pytest.FixtureRequest, +) -> None: + """server.sessions should hide internal control mode session.""" + engine_name = request.config.getoption("--engine", default="subprocess") + if engine_name != "control": + pytest.skip("Control mode only") + + # Create user session + user_session = server.new_session(session_name="my_app_session") + + # With bootstrap approach, control mode attaches to "tmuxp" session + # Both "tmuxp" and user session are visible (tmuxp is reused, not internal) + assert len(server.sessions) == 2 + session_names = [s.name for s in server.sessions] + assert "my_app_session" in session_names + assert "tmuxp" in session_names + + # Cleanup + user_session.kill() + + +def test_has_session_excludes_control_mode( + server: Server, + request: pytest.FixtureRequest, +) -> None: + """has_session should return False for internal control session.""" + engine_name = request.config.getoption("--engine", default="subprocess") + if engine_name != "control": + pytest.skip("Control mode only") + + # With bootstrap approach, control mode attaches to "tmuxp" (which IS visible) + assert server.has_session("tmuxp") + # Internal session (libtmux_ctrl_*) should be filtered from has_session() + # The old hard-coded name is no longer used; now uses UUID-based names + assert not server.has_session("libtmux_control_mode") + + +def test_session_count_engine_agnostic( + server: Server, + session: Session, +) -> None: + """Session count should be engine-agnostic (excluding internal).""" + # Both engines should show same pattern + # session fixture creates one test session + # Subprocess: 1 test session + # Control: 1 test session + 1 internal (filtered) + + # Find test sessions (created by fixture with TEST_SESSION_PREFIX) + test_sessions = [ + s for s in server.sessions if s.name and s.name.startswith("libtmux_") + ] + assert len(test_sessions) >= 1 # At least the fixture's session + + @pytest.mark.skipif(True, reason="tmux 3.2 returns wrong width - test needs rework") def test_new_session_width_height(server: Server) -> None: """Verify ``Server.new_session`` creates valid session running w/ dimensions.""" diff --git a/tests/test_session.py b/tests/test_session.py index 4ffba5a89..94366ec8e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -16,6 +16,7 @@ from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import namer from libtmux.window import Window +from tests.helpers import wait_for_line if t.TYPE_CHECKING: from libtmux._internal.types import StrPath @@ -330,7 +331,14 @@ def test_new_window_with_environment( assert pane is not None for k, v in environment.items(): pane.send_keys(f"echo ${k}") - assert pane.capture_pane()[-2] == v + + def _match(line: str, expected: str = v) -> bool: + stripped = line.strip() + # Match exact value or value after shell prompt ($ prefix) + return stripped == expected or stripped == f"$ {expected}" + + lines = wait_for_line(pane, _match) + assert any(_match(line) for line in lines) def test_session_new_window_with_direction( diff --git a/tests/test_window.py b/tests/test_window.py index 084ba407e..4adb8cc2d 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -5,7 +5,6 @@ import logging import pathlib import shutil -import time import typing as t import pytest @@ -21,6 +20,7 @@ from libtmux.pane import Pane from libtmux.server import Server from libtmux.window import Window +from tests.helpers import wait_for_line if t.TYPE_CHECKING: from libtmux._internal.types import StrPath @@ -458,11 +458,16 @@ def test_split_with_environment( environment=environment, ) assert pane is not None - # wait a bit for the prompt to be ready as the test gets flaky otherwise - time.sleep(0.05) for k, v in environment.items(): pane.send_keys(f"echo ${k}") - assert pane.capture_pane()[-2] == v + + def _match(line: str, expected: str = v) -> bool: + stripped = line.strip() + # Match exact value or value after shell prompt ($ prefix) + return stripped == expected or stripped == f"$ {expected}" + + lines = wait_for_line(pane, _match) + assert any(_match(line) for line in lines) def test_split_window_zoom(