|
| 1 | +--- |
| 2 | +orphan: true |
| 3 | +--- |
| 4 | + |
| 5 | +(control-mode)= |
| 6 | + |
| 7 | +# Control Mode Engine (experimental) |
| 8 | + |
| 9 | +:::{warning} |
| 10 | +This is an **experimental API**. Names and behavior may change between releases. |
| 11 | +Use with caution and pin your libtmux version if you depend on it. |
| 12 | +::: |
| 13 | + |
| 14 | +libtmux can drive tmux through a persistent Control Mode client. This keeps a |
| 15 | +single connection open, pipelines commands, and surfaces tmux notifications in a |
| 16 | +typed stream. |
| 17 | + |
| 18 | +## Why use Control Mode? |
| 19 | + |
| 20 | +- Lower overhead than spawning a tmux process per command |
| 21 | +- Access to live notifications: layout changes, window/link events, client |
| 22 | + detach/attach, paste buffer updates, and more |
| 23 | +- Structured command results with timing/flag metadata |
| 24 | + |
| 25 | +## Using ControlModeEngine |
| 26 | + |
| 27 | +```python |
| 28 | +from __future__ import annotations |
| 29 | + |
| 30 | +from libtmux._internal.engines.control_mode import ControlModeEngine |
| 31 | +from libtmux.server import Server |
| 32 | + |
| 33 | +engine = ControlModeEngine(command_timeout=5) |
| 34 | +server = Server(engine=engine) |
| 35 | + |
| 36 | +session = server.new_session(session_name="ctrl-demo") |
| 37 | +print(session.name) |
| 38 | + |
| 39 | +# Consume notifications (non-blocking example) |
| 40 | +for notif in engine.iter_notifications(timeout=0.1): |
| 41 | + print(notif.kind, notif.data) |
| 42 | +``` |
| 43 | + |
| 44 | +:::{note} |
| 45 | +Control mode creates an internal session for connection management (default name: |
| 46 | +`libtmux_control_mode`). This session is automatically filtered from |
| 47 | +`Server.sessions` and `Server.has_session()` to maintain engine transparency. |
| 48 | +::: |
| 49 | + |
| 50 | +## Session management with Control Mode |
| 51 | + |
| 52 | +The {meth}`Server.connect()` method works seamlessly with control mode: |
| 53 | + |
| 54 | +```python |
| 55 | +from libtmux._internal.engines.control_mode import ControlModeEngine |
| 56 | +from libtmux.server import Server |
| 57 | + |
| 58 | +engine = ControlModeEngine() |
| 59 | +server = Server(engine=engine) |
| 60 | + |
| 61 | +# Reuses session if it exists, creates if it doesn't |
| 62 | +session = server.connect("dev-session") |
| 63 | +print(session.name) |
| 64 | + |
| 65 | +# Calling again returns the same session |
| 66 | +session2 = server.connect("dev-session") |
| 67 | +assert session2.session_id == session.session_id |
| 68 | +``` |
| 69 | + |
| 70 | +This works transparently with both control mode and subprocess engines, making it |
| 71 | +easy to switch between them without changing your code. |
| 72 | + |
| 73 | +## Advanced Configuration |
| 74 | + |
| 75 | +### Custom Internal Session Name |
| 76 | + |
| 77 | +For testing or advanced scenarios, you can customize the internal session name: |
| 78 | + |
| 79 | +```python |
| 80 | +from libtmux._internal.engines.control_mode import ControlModeEngine |
| 81 | +from libtmux.server import Server |
| 82 | + |
| 83 | +engine = ControlModeEngine(internal_session_name="my_control_session") |
| 84 | +server = Server(engine=engine) |
| 85 | + |
| 86 | +# Internal session is still filtered |
| 87 | +user_session = server.new_session("my_app") |
| 88 | +len(server.sessions) # 1 (only my_app visible) |
| 89 | + |
| 90 | +# But exists internally |
| 91 | +len(server._sessions_all()) # 2 (my_app + my_control_session) |
| 92 | +``` |
| 93 | + |
| 94 | +### Attach to Existing Session |
| 95 | + |
| 96 | +For expert use cases, control mode can attach to an existing session instead of |
| 97 | +creating an internal one: |
| 98 | + |
| 99 | +```python |
| 100 | +# Create a session first |
| 101 | +server.new_session("shared") |
| 102 | + |
| 103 | +# Control mode attaches to it for its connection |
| 104 | +engine = ControlModeEngine(attach_to="shared") |
| 105 | +server = Server(engine=engine) |
| 106 | + |
| 107 | +# The shared session is visible (not filtered) |
| 108 | +len(server.sessions) # 1 (shared session) |
| 109 | +``` |
| 110 | + |
| 111 | +:::{warning} |
| 112 | +Attaching to active user sessions will generate notification traffic from pane |
| 113 | +output and layout changes. This increases protocol parsing overhead and may impact |
| 114 | +performance. Use only when you need control mode notifications for a specific session. |
| 115 | +::: |
| 116 | + |
| 117 | +## Parsing notifications directly |
| 118 | + |
| 119 | +The protocol parser can be used without tmux to understand the wire format. |
| 120 | + |
| 121 | +```python |
| 122 | +>>> from libtmux._internal.engines.control_protocol import ControlProtocol |
| 123 | +>>> proto = ControlProtocol() |
| 124 | +>>> proto.feed_line("%layout-change @1 abcd efgh 0") |
| 125 | +>>> notif = proto.get_notification() |
| 126 | +>>> notif.kind.name |
| 127 | +'WINDOW_LAYOUT_CHANGED' |
| 128 | +>>> notif.data['window_layout'] |
| 129 | +'abcd' |
| 130 | +``` |
| 131 | + |
| 132 | +## Fallback engine |
| 133 | + |
| 134 | +If control mode is unavailable, ``SubprocessEngine`` matches the same |
| 135 | +``Engine`` interface but runs one tmux process per command: |
| 136 | + |
| 137 | +```python |
| 138 | +from libtmux._internal.engines.subprocess_engine import SubprocessEngine |
| 139 | +from libtmux.server import Server |
| 140 | + |
| 141 | +server = Server(engine=SubprocessEngine()) |
| 142 | +print(server.list_sessions()) # legacy behavior |
| 143 | +``` |
| 144 | + |
| 145 | +## Key behaviors |
| 146 | + |
| 147 | +- Commands still return ``tmux_cmd`` objects for compatibility, but extra |
| 148 | + metadata (``exit_status``, ``cmd_id``, ``tmux_time``) is attached. |
| 149 | +- Notifications are queued; drops are counted when consumers fall behind. |
| 150 | +- Timeouts raise ``ControlModeTimeout`` and restart the control client. |
| 151 | + |
| 152 | +## Errors, timeouts, and retries |
| 153 | + |
| 154 | +- ``ControlModeTimeout`` — command block did not finish before |
| 155 | + ``command_timeout``. The engine closes and restarts the control client. |
| 156 | +- ``ControlModeConnectionError`` — control socket died (EOF/broken pipe). The |
| 157 | + engine restarts and replays the pending command once. |
| 158 | +- ``ControlModeProtocolError`` — malformed ``%begin/%end/%error`` framing; the |
| 159 | + client is marked dead and must be restarted. |
| 160 | +- ``SubprocessTimeout`` — subprocess fallback exceeded its timeout. |
| 161 | + |
| 162 | +## Notifications and backpressure |
| 163 | + |
| 164 | +- Notifications are enqueued in a bounded queue (default 4096). When the queue |
| 165 | + fills, additional notifications are dropped and the drop counter is reported |
| 166 | + via :class:`~libtmux._internal.engines.base.EngineStats`. |
| 167 | +- Consume notifications via :meth:`ControlModeEngine.iter_notifications` to |
| 168 | + avoid drops; use a small timeout (e.g., 0.1s) for non-blocking loops. |
| 169 | + |
| 170 | +## Environment propagation requirements |
| 171 | + |
| 172 | +- tmux **3.2 or newer** is required for ``-e KEY=VAL`` on ``new-session``, |
| 173 | + ``new-window``, and ``split-window``. Older tmux versions ignore ``-e``; the |
| 174 | + library emits a warning and tests skip these cases. |
| 175 | +- Environment tests and examples may wait briefly after ``send-keys`` so the |
| 176 | + shell prompt/output reaches the pane before capture. |
| 177 | + |
| 178 | +## Capture-pane normalization |
| 179 | + |
| 180 | +- Control mode trims trailing *whitespace-only* lines from ``capture-pane`` to |
| 181 | + match subprocess behaviour. If you request explicit ranges (``-S/-E``) or use |
| 182 | + ``-N/-J``, output is left untouched. |
| 183 | +- In control mode, the first capture after ``send-keys`` can race the shell; |
| 184 | + libtmux retries briefly to ensure the prompt/output is visible. |
| 185 | + |
| 186 | +## Control sandbox (tests/diagnostics) |
| 187 | + |
| 188 | +The pytest fixture ``control_sandbox`` provides an isolated control-mode tmux |
| 189 | +server with a unique socket, HOME/TMUX_TMPDIR isolation, and automatic cleanup. |
| 190 | +It is used by the regression suite and can be reused in custom tests when you |
| 191 | +need a hermetic control-mode client. |
0 commit comments