Skip to content

Commit 8659d87

Browse files
committed
feat(ControlMode): filter internal session + add configurability
Phase 1: Automatic Internal Session Filtering - Filter control mode's internal session from Server.sessions property - Update has_session() to exclude internal sessions - Add Server._sessions_all() for advanced debugging - Add Server._get_internal_session_names() to query engine for internal sessions Phase 2: Engine Configurability - Add session_name parameter to ControlModeEngine for custom internal session names - Add attach_to parameter to attach to existing sessions (advanced opt-in) - Update _start_process() to handle both new-session and attach-session modes - Dynamic filtering based on engine configuration Tests: - test_sessions_excludes_internal_control_mode: verify filtering works - test_has_session_excludes_control_mode: verify has_session consistency - test_session_count_engine_agnostic: verify engine transparency - test_control_mode_custom_session_name: verify custom naming - test_control_mode_attach_to_existing: verify attach mode Documentation: - Update control_mode.md with filtering behavior and advanced options - Add warnings about attach_to notification spam - Update CHANGES with features and compatibility notes This makes control mode engine truly transparent - users see the same session behavior regardless of which engine they choose. Internal sessions are used for connection management but hidden from user-facing APIs. Related: #605
1 parent bc2a0a8 commit 8659d87

File tree

6 files changed

+310
-33
lines changed

6 files changed

+310
-33
lines changed

CHANGES

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,22 @@ _Future release notes will be placed here_
4040
notification parsing (layout changes, unlinked windows, client detach/session change,
4141
session rename, paste-buffer events), and stats while keeping existing
4242
`Server/Session/Window/Pane.cmd` return type (`tmux_cmd`) stable. (#605)
43+
- Control mode engine's internal connection session is now automatically filtered from
44+
`Server.sessions` and `Server.has_session()`, making engine choice transparent to
45+
users. Advanced users can access all sessions via `Server._sessions_all()`. (#605)
46+
- `ControlModeEngine` accepts optional `session_name` and `attach_to` parameters for
47+
advanced session management scenarios. (#605)
4348
- `Server.connect()`: New convenience method for session management. Returns an
4449
existing session if found, otherwise creates a new detached session. Simplifies
4550
common session reuse patterns and works transparently with both subprocess and
4651
control-mode engines.
4752

4853
### Compatibility
4954

50-
- Control mode creates a bootstrap session named ``libtmux_control_mode``; callers that
51-
enumerate sessions should ignore or filter it. APIs remain unchanged for tmux command
52-
return objects; new metadata is attached for advanced users. (#605)
55+
- Control mode's internal session is now automatically filtered from user-facing APIs.
56+
Code that previously filtered `libtmux_control_mode` manually can be simplified.
57+
APIs remain unchanged for tmux command return objects; new metadata is attached for
58+
advanced users. (#605)
5359

5460
### Testing
5561

docs/topics/control_mode.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ for notif in engine.iter_notifications(timeout=0.1):
4242
```
4343

4444
:::{note}
45-
Control mode creates a bootstrap tmux session named ``libtmux_control_mode``.
46-
If your code enumerates sessions, filter it out.
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.
4748
:::
4849

4950
## Session management with Control Mode
@@ -69,6 +70,50 @@ assert session2.session_id == session.session_id
6970
This works transparently with both control mode and subprocess engines, making it
7071
easy to switch between them without changing your code.
7172

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(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+
72117
## Parsing notifications directly
73118

74119
The protocol parser can be used without tmux to understand the wire format.

src/libtmux/_internal/engines/control_mode.py

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,41 @@
2626

2727

2828
class ControlModeEngine(Engine):
29-
"""Engine that runs tmux commands via a persistent Control Mode process."""
29+
"""Engine that runs tmux commands via a persistent Control Mode process.
30+
31+
By default, creates an internal session for connection management.
32+
This session is hidden from user-facing APIs like Server.sessions.
33+
"""
3034

3135
def __init__(
3236
self,
3337
command_timeout: float | None = 10.0,
3438
notification_queue_size: int = 4096,
39+
session_name: str | None = None,
40+
attach_to: str | None = None,
3541
) -> None:
42+
"""Initialize control mode engine.
43+
44+
Parameters
45+
----------
46+
command_timeout : float, optional
47+
Timeout for tmux commands in seconds. Default: 10.0
48+
notification_queue_size : int
49+
Size of notification queue. Default: 4096
50+
session_name : str, optional
51+
Custom name for internal control session.
52+
Default: "libtmux_control_mode"
53+
54+
The internal session is used for connection management and is
55+
automatically filtered from user-facing APIs.
56+
attach_to : str, optional
57+
Attach to existing session instead of creating internal one.
58+
When set, control mode attaches to this session for its connection.
59+
60+
.. warning::
61+
Attaching to user sessions can cause notification spam from
62+
pane output. Use for advanced scenarios only.
63+
"""
3664
self.process: subprocess.Popen[str] | None = None
3765
self._lock = threading.Lock()
3866
self._server_args: tuple[str | int, ...] | None = None
@@ -45,6 +73,8 @@ def __init__(
4573
notification_queue_size=notification_queue_size,
4674
)
4775
self._restarts = 0
76+
self._session_name = session_name or "libtmux_control_mode"
77+
self._attach_to = attach_to
4878

4979
# Lifecycle ---------------------------------------------------------
5080
def close(self) -> None:
@@ -167,15 +197,43 @@ def _start_process(self, server_args: tuple[str | int, ...]) -> None:
167197
notification_queue_size=self._notification_queue_size,
168198
)
169199

170-
cmd = [
171-
tmux_bin,
172-
*[str(a) for a in server_args],
173-
"-C",
174-
"new-session",
175-
"-A",
176-
"-s",
177-
"libtmux_control_mode",
178-
]
200+
# Build command based on configuration
201+
if self._attach_to:
202+
# Attach to existing session (advanced mode)
203+
cmd = [
204+
tmux_bin,
205+
*[str(a) for a in server_args],
206+
"-C",
207+
"attach-session",
208+
"-t",
209+
self._attach_to,
210+
]
211+
bootstrap_argv = [
212+
tmux_bin,
213+
*[str(a) for a in server_args],
214+
"attach-session",
215+
"-t",
216+
self._attach_to,
217+
]
218+
else:
219+
# Create or attach to internal session (default)
220+
cmd = [
221+
tmux_bin,
222+
*[str(a) for a in server_args],
223+
"-C",
224+
"new-session",
225+
"-A",
226+
"-s",
227+
self._session_name,
228+
]
229+
bootstrap_argv = [
230+
tmux_bin,
231+
*[str(a) for a in server_args],
232+
"new-session",
233+
"-A",
234+
"-s",
235+
self._session_name,
236+
]
179237

180238
logger.debug("Starting Control Mode process: %s", cmd)
181239
self.process = subprocess.Popen(
@@ -188,18 +246,9 @@ def _start_process(self, server_args: tuple[str | int, ...]) -> None:
188246
errors="backslashreplace",
189247
)
190248

191-
# The initial command (new-session) emits an output block; register
192-
# a context so the protocol can consume it.
193-
bootstrap_ctx = CommandContext(
194-
argv=[
195-
tmux_bin,
196-
*[str(a) for a in server_args],
197-
"new-session",
198-
"-A",
199-
"-s",
200-
"libtmux_control_mode",
201-
],
202-
)
249+
# The initial command (new-session or attach-session) emits an output
250+
# block; register a context so the protocol can consume it.
251+
bootstrap_ctx = CommandContext(argv=bootstrap_argv)
203252
self._protocol.register_command(bootstrap_ctx)
204253

205254
# Start IO threads after registration to avoid early protocol errors.

src/libtmux/server.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,10 @@ def attached_sessions(self) -> list[Session]:
327327
return self.sessions.filter(session_attached__noeq="1")
328328

329329
def has_session(self, target_session: str, exact: bool = True) -> bool:
330-
"""Return True if session exists.
330+
"""Return True if session exists (excluding internal engine sessions).
331+
332+
Internal sessions used by engines for connection management are
333+
excluded to maintain engine transparency.
331334
332335
Parameters
333336
----------
@@ -348,6 +351,11 @@ def has_session(self, target_session: str, exact: bool = True) -> bool:
348351
"""
349352
session_check_name(target_session)
350353

354+
# Never report internal engine sessions as existing
355+
internal_names = self._get_internal_session_names()
356+
if target_session in internal_names:
357+
return False
358+
351359
if exact and has_gte_version("2.1"):
352360
target_session = f"={target_session}"
353361

@@ -675,14 +683,60 @@ def new_session(
675683
#
676684
# Relations
677685
#
686+
def _get_internal_session_names(self) -> set[str]:
687+
"""Get session names used internally by the engine.
688+
689+
These sessions are used for engine management (e.g., control mode
690+
connection) and should be hidden from user-facing APIs.
691+
692+
Returns
693+
-------
694+
set[str]
695+
Set of internal session names to filter.
696+
"""
697+
from libtmux._internal.engines.control_mode import ControlModeEngine
698+
699+
if isinstance(self.engine, ControlModeEngine) and not self.engine._attach_to:
700+
# Only filter if not attaching to user session
701+
return {self.engine._session_name}
702+
703+
return set()
704+
678705
@property
679706
def sessions(self) -> QueryList[Session]:
680-
"""Sessions contained in server.
707+
"""Sessions contained in server (excluding internal engine sessions).
708+
709+
Internal sessions are used by engines for connection management
710+
(e.g., control mode maintains a persistent connection session).
711+
These are automatically filtered to maintain engine transparency.
712+
713+
For advanced debugging, use the internal :meth:`._sessions_all()` method.
681714
682715
Can be accessed via
683716
:meth:`.sessions.get() <libtmux._internal.query_list.QueryList.get()>` and
684717
:meth:`.sessions.filter() <libtmux._internal.query_list.QueryList.filter()>`
685718
"""
719+
all_sessions = self._sessions_all()
720+
721+
# Filter out internal engine sessions
722+
internal_names = self._get_internal_session_names()
723+
filtered_sessions = [
724+
s for s in all_sessions if s.session_name not in internal_names
725+
]
726+
727+
return QueryList(filtered_sessions)
728+
729+
def _sessions_all(self) -> QueryList[Session]:
730+
"""Return all sessions including internal engine sessions.
731+
732+
Used internally for engine management and advanced debugging.
733+
Most users should use the :attr:`.sessions` property instead.
734+
735+
Returns
736+
-------
737+
QueryList[Session]
738+
All sessions including internal ones used by engines.
739+
"""
686740
sessions: list[Session] = []
687741

688742
try:

tests/test_control_mode_engine.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@ def test_control_mode_engine_basic(tmp_path: pathlib.Path) -> None:
3737
assert engine.process.poll() is None
3838

3939
# list sessions
40-
# ControlModeEngine creates a bootstrap session "libtmux_control_mode", so we
41-
# expect 2 sessions
40+
# Control mode bootstrap session is now filtered from server.sessions
4241
sessions = server.sessions
43-
assert len(sessions) >= 1
42+
assert len(sessions) == 1
4443
session_names = [s.name for s in sessions]
4544
assert "test_sess" in session_names
46-
assert "libtmux_control_mode" in session_names
45+
46+
# Verify bootstrap session exists but is filtered (use internal method)
47+
all_sessions = server._sessions_all()
48+
all_session_names = [s.name for s in all_sessions]
49+
assert "libtmux_control_mode" in all_session_names
50+
assert len(all_sessions) == 2 # test_sess + libtmux_control_mode
4751

4852
# run a command that returns output
4953
output_cmd = server.cmd("display-message", "-p", "hello")
@@ -106,3 +110,66 @@ def fake_start(server_args: t.Sequence[str | int] | None) -> None:
106110
engine.run("list-sessions")
107111

108112
assert engine.process is None
113+
114+
115+
def test_control_mode_custom_session_name(tmp_path: pathlib.Path) -> None:
116+
"""Control mode engine can use custom internal session name."""
117+
socket_path = tmp_path / "tmux-custom-session-test"
118+
engine = ControlModeEngine(session_name="my_control_session")
119+
server = Server(socket_path=socket_path, engine=engine)
120+
121+
# Cleanup if exists
122+
if server.is_alive():
123+
server.kill()
124+
125+
# Create user session
126+
server.new_session(session_name="user_app")
127+
128+
# Only user session visible via public API
129+
assert len(server.sessions) == 1
130+
assert server.sessions[0].name == "user_app"
131+
132+
# Custom internal session exists but is filtered
133+
all_sessions = server._sessions_all()
134+
all_names = [s.name for s in all_sessions]
135+
assert "my_control_session" in all_names
136+
assert "user_app" in all_names
137+
assert len(all_sessions) == 2
138+
139+
# Cleanup
140+
server.kill()
141+
assert engine.process is not None
142+
engine.process.wait(timeout=2)
143+
144+
145+
def test_control_mode_attach_to_existing(tmp_path: pathlib.Path) -> None:
146+
"""Control mode can attach to existing session (advanced opt-in)."""
147+
socket_path = tmp_path / "tmux-attach-test"
148+
149+
# Create session first with subprocess engine
150+
from libtmux._internal.engines.subprocess_engine import SubprocessEngine
151+
152+
subprocess_engine = SubprocessEngine()
153+
server1 = Server(socket_path=socket_path, engine=subprocess_engine)
154+
155+
if server1.is_alive():
156+
server1.kill()
157+
158+
server1.new_session(session_name="shared_session")
159+
160+
# Control mode attaches to existing session (no internal session created)
161+
control_engine = ControlModeEngine(attach_to="shared_session")
162+
server2 = Server(socket_path=socket_path, engine=control_engine)
163+
164+
# Should see the shared session
165+
assert len(server2.sessions) == 1
166+
assert server2.sessions[0].name == "shared_session"
167+
168+
# No internal session was created
169+
all_sessions = server2._sessions_all()
170+
assert len(all_sessions) == 1 # Only shared_session
171+
172+
# Cleanup
173+
server2.kill()
174+
assert control_engine.process is not None
175+
control_engine.process.wait(timeout=2)

0 commit comments

Comments
 (0)