Skip to content

Commit 944879d

Browse files
committed
ControlModeEngine(core): UUID session names, control-mode flag, drain helper
- Use UUID-based internal session names (libtmux_ctrl_{uuid}) to avoid collisions when multiple control engines run simultaneously - Fix control flag detection: use "control-mode" string (confirmed from tmux C source server-client.c:3776) instead of "C" letter - Add drain_notifications() helper for explicit sync points when using attach_to mode or waiting for notification activity to settle - Use wait_for_line() synchronization for capture-pane tests to avoid timing races with shell output - Enhance exception docstrings: ControlModeTimeout is terminal (not retried), ControlModeConnectionError is retriable - Propagate ControlModeConnectionError/Timeout in _sessions_all() - Remove 7 xfails that now pass after synchronization fixes Note: 3 ScriptedProcess/ProcessFactory tests remain failing due to threading model issues (daemon threads + tuple stdout = race condition). These will be addressed in subsequent commits.
1 parent cfcc986 commit 944879d

File tree

6 files changed

+228
-80
lines changed

6 files changed

+228
-80
lines changed

src/libtmux/_internal/engines/control_mode.py

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
import threading
1010
import typing as t
11+
import uuid
1112

1213
from libtmux import exc
1314
from libtmux._internal.engines.base import (
@@ -67,10 +68,20 @@ class ControlModeEngine(Engine):
6768
By default, creates an internal session for connection management.
6869
This session is hidden from user-facing APIs like Server.sessions.
6970
70-
Commands raise :class:`~libtmux.exc.ControlModeTimeout` or
71-
:class:`~libtmux.exc.ControlModeConnectionError` on stalls/disconnects; a
72-
bounded notification queue (default 4096) records out-of-band events with
73-
drop counting when consumers fall behind.
71+
Error Handling
72+
--------------
73+
Connection errors (BrokenPipeError, EOF) raise
74+
:class:`~libtmux.exc.ControlModeConnectionError` and are automatically
75+
retried up to ``max_retries`` times (default: 1).
76+
77+
Timeouts raise :class:`~libtmux.exc.ControlModeTimeout` and are NOT retried.
78+
If operations frequently timeout, increase ``command_timeout``.
79+
80+
Notifications
81+
-------------
82+
A bounded notification queue (default 4096) records out-of-band events with
83+
drop counting when consumers fall behind. Use :meth:`iter_notifications` to
84+
consume events or :meth:`drain_notifications` to wait for idle state.
7485
"""
7586

7687
def __init__(
@@ -93,10 +104,11 @@ def __init__(
93104
Size of notification queue. Default: 4096
94105
internal_session_name : str, optional
95106
Custom name for internal control session.
96-
Default: "libtmux_control_mode"
107+
Default: Auto-generated unique name (libtmux_ctrl_XXXXXXXX)
97108
98109
The internal session is used for connection management and is
99-
automatically filtered from user-facing APIs.
110+
automatically filtered from user-facing APIs. A unique name is
111+
generated automatically to avoid collisions with user sessions.
100112
attach_to : str, optional
101113
Attach to existing session instead of creating internal one.
102114
When set, control mode attaches to this session for its connection.
@@ -127,7 +139,9 @@ def __init__(
127139
notification_queue_size=notification_queue_size,
128140
)
129141
self._restarts = 0
130-
self._internal_session_name = internal_session_name or "libtmux_control_mode"
142+
self._internal_session_name = (
143+
internal_session_name or f"libtmux_ctrl_{uuid.uuid4().hex[:8]}"
144+
)
131145
self._attach_to = attach_to
132146
self._process_factory = process_factory
133147
self._max_retries = max(0, max_retries)
@@ -229,6 +243,54 @@ def iter_notifications(
229243
return
230244
yield notif
231245

246+
def drain_notifications(
247+
self,
248+
*,
249+
idle_duration: float = 0.1,
250+
timeout: float = 8.0,
251+
) -> list[Notification]:
252+
"""Drain notifications until the queue is idle.
253+
254+
This helper is useful when you need to wait for notification activity
255+
to settle after an operation that may generate multiple notifications
256+
(e.g., attach-session in attach_to mode).
257+
258+
Parameters
259+
----------
260+
idle_duration : float, optional
261+
Consider the queue idle after this many seconds of silence.
262+
Default: 0.1 (100ms)
263+
timeout : float, optional
264+
Maximum time to wait for idle state. Default: 8.0
265+
Matches RETRY_TIMEOUT_SECONDS from libtmux.test.retry.
266+
267+
Returns
268+
-------
269+
list[Notification]
270+
All notifications received before idle state.
271+
272+
Raises
273+
------
274+
TimeoutError
275+
If timeout is reached before idle state.
276+
"""
277+
import time
278+
279+
collected: list[Notification] = []
280+
deadline = time.monotonic() + timeout
281+
282+
while time.monotonic() < deadline:
283+
notif = self._protocol.get_notification(timeout=idle_duration)
284+
if notif is None:
285+
# Queue was idle for idle_duration - we're done
286+
return collected
287+
if notif.kind.name == "EXIT":
288+
return collected
289+
collected.append(notif)
290+
291+
msg = f"Notification queue did not become idle within {timeout}s"
292+
raise TimeoutError(msg)
293+
232294
def get_stats(self) -> EngineStats:
233295
"""Return diagnostic statistics for the engine."""
234296
return self._protocol.get_stats(restarts=self._restarts)
@@ -281,7 +343,7 @@ def exclude_internal_sessions(
281343
non_control_clients = [
282344
(pid, flags)
283345
for pid, flags in clients
284-
if "C" not in flags and pid != ctrl_pid
346+
if "control-mode" not in flags and pid != ctrl_pid
285347
]
286348

287349
if non_control_clients:
@@ -310,7 +372,7 @@ def can_switch_client(
310372
parts = line.split()
311373
if len(parts) >= 2:
312374
pid, flags = parts[0], parts[1]
313-
if "C" not in flags and pid != ctrl_pid:
375+
if "control-mode" not in flags and pid != ctrl_pid:
314376
return True
315377

316378
return False

src/libtmux/exc.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,27 @@ class WaitTimeout(LibTmuxException):
9595
class ControlModeTimeout(LibTmuxException):
9696
"""tmux control-mode command did not return before the configured timeout.
9797
98+
This is a **terminal failure** - the operation took too long and is NOT
99+
automatically retried. If you expect slow operations, increase the timeout
100+
parameter when creating the engine or calling commands.
101+
102+
This is distinct from :class:`ControlModeConnectionError`, which indicates
103+
a transient connection failure (like BrokenPipeError) and IS automatically
104+
retried up to ``max_retries`` times.
105+
98106
Raised by :class:`~libtmux._internal.engines.control_mode.ControlModeEngine`
99-
when a command block fails to finish. The engine will close and restart the
100-
control client after emitting this error.
107+
when a command block fails to finish within the timeout. The engine will
108+
close and restart the control client after emitting this error, but will NOT
109+
retry the timed-out command.
110+
111+
If commands timeout frequently, increase the timeout::
112+
113+
engine = ControlModeEngine(command_timeout=30.0)
114+
server = Server(engine=engine)
115+
116+
Or override per-command::
117+
118+
server.cmd("slow-command", timeout=60.0)
101119
"""
102120

103121

@@ -110,7 +128,18 @@ class ControlModeProtocolError(LibTmuxException):
110128

111129

112130
class ControlModeConnectionError(LibTmuxException):
113-
"""Control-mode connection was lost unexpectedly (EOF/broken pipe)."""
131+
"""Control-mode connection was lost unexpectedly (EOF/broken pipe).
132+
133+
This is a **retriable error** - the engine will automatically retry the
134+
command up to ``max_retries`` times (default: 1) after restarting the
135+
control client connection.
136+
137+
This is distinct from :class:`ControlModeTimeout`, which indicates the
138+
operation took too long and is NOT automatically retried.
139+
140+
Raised when writing to the control-mode process fails with BrokenPipeError
141+
or when unexpected EOF is encountered.
142+
"""
114143

115144

116145
class SubprocessTimeout(LibTmuxException):

src/libtmux/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,11 @@ def _sessions_all(self) -> QueryList[Session]:
799799
server=self,
800800
):
801801
sessions.append(Session(server=self, **obj)) # noqa: PERF401
802+
except (exc.ControlModeConnectionError, exc.ControlModeTimeout):
803+
# Propagate control mode connection/timeout errors
804+
raise
802805
except Exception:
806+
# Catch other exceptions (e.g., no sessions exist)
803807
pass
804808

805809
return QueryList(sessions)

tests/test_control_mode_engine.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,12 @@ def test_control_mode_engine_basic(tmp_path: pathlib.Path) -> None:
4848
# Verify bootstrap session exists but is filtered (use internal method)
4949
all_sessions = server._sessions_all()
5050
all_session_names = [s.name for s in all_sessions]
51-
assert "libtmux_control_mode" in all_session_names
52-
assert len(all_sessions) == 2 # test_sess + libtmux_control_mode
51+
# Internal session now uses UUID-based name: libtmux_ctrl_XXXXXXXX
52+
assert any(
53+
name is not None and name.startswith("libtmux_ctrl_")
54+
for name in all_session_names
55+
)
56+
assert len(all_sessions) == 2 # test_sess + libtmux_ctrl_*
5357

5458
# run a command that returns output
5559
output_cmd = server.cmd("display-message", "-p", "hello")

0 commit comments

Comments
 (0)