Skip to content

Commit afcc927

Browse files
committed
Server(refactor[sessions]): Use engine internal filters
why: keep engine transparency without reaching into control-mode internals. what: - hide management sessions via engine.internal_session_names - route attached_sessions through engine.exclude_internal_sessions hook - preserve existing server arg handling and attach behaviour
1 parent 412f0d6 commit afcc927

File tree

1 file changed

+216
-29
lines changed

1 file changed

+216
-29
lines changed

src/libtmux/server.py

Lines changed: 216 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import warnings
1717

1818
from libtmux import exc, formats
19+
from libtmux._internal.engines.subprocess_engine import SubprocessEngine
1920
from libtmux._internal.query_list import QueryList
2021
from libtmux.common import tmux_cmd
2122
from libtmux.neo import fetch_objs
@@ -38,6 +39,7 @@
3839

3940
from typing_extensions import Self
4041

42+
from libtmux._internal.engines.base import Engine
4143
from libtmux._internal.types import StrPath
4244

4345
DashLiteral: TypeAlias = t.Literal["-"]
@@ -72,17 +74,17 @@ class Server(EnvironmentMixin):
7274
>>> server
7375
Server(socket_name=libtmux_test...)
7476
75-
>>> server.sessions
76-
[Session($1 ...)]
77+
>>> server.sessions # doctest: +ELLIPSIS
78+
[Session($... ...)]
7779
78-
>>> server.sessions[0].windows
79-
[Window(@1 1:..., Session($1 ...))]
80+
>>> server.sessions[0].windows # doctest: +ELLIPSIS
81+
[Window(@... ..., Session($... ...))]
8082
81-
>>> server.sessions[0].active_window
82-
Window(@1 1:..., Session($1 ...))
83+
>>> server.sessions[0].active_window # doctest: +ELLIPSIS
84+
Window(@... ..., Session($... ...))
8385
84-
>>> server.sessions[0].active_pane
85-
Pane(%1 Window(@1 1:..., Session($1 ...)))
86+
>>> server.sessions[0].active_pane # doctest: +ELLIPSIS
87+
Pane(%... Window(@... ..., Session($... ...)))
8688
8789
The server can be used as a context manager to ensure proper cleanup:
8890
@@ -126,12 +128,17 @@ def __init__(
126128
colors: int | None = None,
127129
on_init: t.Callable[[Server], None] | None = None,
128130
socket_name_factory: t.Callable[[], str] | None = None,
131+
engine: Engine | None = None,
129132
**kwargs: t.Any,
130133
) -> None:
131134
EnvironmentMixin.__init__(self, "-g")
132135
self._windows: list[WindowDict] = []
133136
self._panes: list[PaneDict] = []
134137

138+
if engine is None:
139+
engine = SubprocessEngine()
140+
self.engine = engine
141+
135142
if socket_path is not None:
136143
self.socket_path = socket_path
137144
elif socket_name is not None:
@@ -194,6 +201,12 @@ def is_alive(self) -> bool:
194201
>>> tmux = Server(socket_name="no_exist")
195202
>>> assert not tmux.is_alive()
196203
"""
204+
# Avoid spinning up control-mode just to probe.
205+
from libtmux._internal.engines.control_mode import ControlModeEngine
206+
207+
if isinstance(self.engine, ControlModeEngine):
208+
return self._probe_server() == 0
209+
197210
try:
198211
res = self.cmd("list-sessions")
199212
except Exception:
@@ -210,23 +223,57 @@ def raise_if_dead(self) -> None:
210223
... print(type(e))
211224
<class 'subprocess.CalledProcessError'>
212225
"""
226+
from libtmux._internal.engines.control_mode import ControlModeEngine
227+
228+
if isinstance(self.engine, ControlModeEngine):
229+
rc = self._probe_server()
230+
if rc != 0:
231+
tmux_bin_probe = shutil.which("tmux") or "tmux"
232+
raise subprocess.CalledProcessError(
233+
returncode=rc,
234+
cmd=[tmux_bin_probe, *self._build_server_args(), "list-sessions"],
235+
)
236+
return
237+
213238
tmux_bin = shutil.which("tmux")
214239
if tmux_bin is None:
215240
raise exc.TmuxCommandNotFound
216241

217-
cmd_args: list[str] = ["list-sessions"]
242+
server_args = self._build_server_args()
243+
proc = self.engine.run("list-sessions", server_args=server_args)
244+
if proc.returncode is not None and proc.returncode != 0:
245+
raise subprocess.CalledProcessError(
246+
returncode=proc.returncode,
247+
cmd=[tmux_bin, *server_args, "list-sessions"],
248+
)
249+
250+
#
251+
# Command
252+
#
253+
def _build_server_args(self) -> list[str]:
254+
"""Return tmux server args based on socket/config settings."""
255+
server_args: list[str] = []
218256
if self.socket_name:
219-
cmd_args.insert(0, f"-L{self.socket_name}")
257+
server_args.append(f"-L{self.socket_name}")
220258
if self.socket_path:
221-
cmd_args.insert(0, f"-S{self.socket_path}")
259+
server_args.append(f"-S{self.socket_path}")
222260
if self.config_file:
223-
cmd_args.insert(0, f"-f{self.config_file}")
261+
server_args.append(f"-f{self.config_file}")
262+
return server_args
224263

225-
subprocess.check_call([tmux_bin, *cmd_args])
264+
def _probe_server(self) -> int:
265+
"""Check server liveness without bootstrapping control mode."""
266+
tmux_bin = shutil.which("tmux")
267+
if tmux_bin is None:
268+
raise exc.TmuxCommandNotFound
269+
270+
result = subprocess.run(
271+
[tmux_bin, *self._build_server_args(), "list-sessions"],
272+
check=False,
273+
capture_output=True,
274+
)
275+
return result.returncode
226276

227-
#
228-
# Command
229-
#
230277
def cmd(
231278
self,
232279
cmd: str,
@@ -280,25 +327,24 @@ def cmd(
280327
281328
Renamed from ``.tmux`` to ``.cmd``.
282329
"""
283-
svr_args: list[str | int] = [cmd]
284-
cmd_args: list[str | int] = []
330+
server_args: list[str | int] = []
285331
if self.socket_name:
286-
svr_args.insert(0, f"-L{self.socket_name}")
332+
server_args.append(f"-L{self.socket_name}")
287333
if self.socket_path:
288-
svr_args.insert(0, f"-S{self.socket_path}")
334+
server_args.append(f"-S{self.socket_path}")
289335
if self.config_file:
290-
svr_args.insert(0, f"-f{self.config_file}")
336+
server_args.append(f"-f{self.config_file}")
291337
if self.colors:
292338
if self.colors == 256:
293-
svr_args.insert(0, "-2")
339+
server_args.append("-2")
294340
elif self.colors == 88:
295-
svr_args.insert(0, "-8")
341+
server_args.append("-8")
296342
else:
297343
raise exc.UnknownColorOption
298344

299-
cmd_args = ["-t", str(target), *args] if target is not None else [*args]
345+
cmd_args = ["-t", str(target), *args] if target is not None else list(args)
300346

301-
return tmux_cmd(*svr_args, *cmd_args)
347+
return self.engine.run(cmd, cmd_args=cmd_args, server_args=server_args)
302348

303349
@property
304350
def attached_sessions(self) -> list[Session]:
@@ -313,10 +359,28 @@ def attached_sessions(self) -> list[Session]:
313359
-------
314360
list of :class:`Session`
315361
"""
316-
return self.sessions.filter(session_attached__noeq="1")
362+
sessions = list(self.sessions.filter(session_attached__noeq="1"))
363+
364+
# Let the engine hide its own internal client if it wants to.
365+
filter_fn = getattr(self.engine, "exclude_internal_sessions", None)
366+
if callable(filter_fn):
367+
server_args = tuple(self._build_server_args())
368+
try:
369+
sessions = filter_fn(
370+
sessions,
371+
server_args=server_args,
372+
)
373+
except TypeError:
374+
# Subprocess engine does not accept server_args; ignore.
375+
sessions = filter_fn(sessions)
376+
377+
return sessions
317378

318379
def has_session(self, target_session: str, exact: bool = True) -> bool:
319-
"""Return True if session exists.
380+
"""Return True if session exists (excluding internal engine sessions).
381+
382+
Internal sessions used by engines for connection management are
383+
excluded to maintain engine transparency.
320384
321385
Parameters
322386
----------
@@ -337,6 +401,11 @@ def has_session(self, target_session: str, exact: bool = True) -> bool:
337401
"""
338402
session_check_name(target_session)
339403

404+
# Never report internal engine sessions as existing
405+
internal_names = self._get_internal_session_names()
406+
if target_session in internal_names:
407+
return False
408+
340409
if exact and has_gte_version("2.1"):
341410
target_session = f"={target_session}"
342411

@@ -402,6 +471,15 @@ def switch_client(self, target_session: str) -> None:
402471
"""
403472
session_check_name(target_session)
404473

474+
server_args = tuple(self._build_server_args())
475+
476+
# If the engine knows there are no "real" clients, mirror tmux's
477+
# `no current client` error before dispatching.
478+
can_switch = getattr(self.engine, "can_switch_client", None)
479+
if callable(can_switch) and not can_switch(server_args=server_args):
480+
msg = "no current client"
481+
raise exc.LibTmuxException(msg)
482+
405483
proc = self.cmd("switch-client", target=target_session)
406484

407485
if proc.stderr:
@@ -425,6 +503,78 @@ def attach_session(self, target_session: str | None = None) -> None:
425503
if proc.stderr:
426504
raise exc.LibTmuxException(proc.stderr)
427505

506+
def connect(self, session_name: str) -> Session:
507+
"""Connect to a session, creating if it doesn't exist.
508+
509+
Returns an existing session if found, otherwise creates a new detached session.
510+
511+
Parameters
512+
----------
513+
session_name : str
514+
Name of the session to connect to.
515+
516+
Returns
517+
-------
518+
:class:`Session`
519+
The connected or newly created session.
520+
521+
Raises
522+
------
523+
:exc:`exc.BadSessionName`
524+
If the session name is invalid (contains '.' or ':').
525+
:exc:`exc.LibTmuxException`
526+
If tmux returns an error.
527+
528+
Examples
529+
--------
530+
>>> session = server.connect('my_session')
531+
>>> session.name
532+
'my_session'
533+
534+
Calling again returns the same session:
535+
536+
>>> session2 = server.connect('my_session')
537+
>>> session2.session_id == session.session_id
538+
True
539+
"""
540+
session_check_name(session_name)
541+
542+
# Check if session already exists
543+
if self.has_session(session_name):
544+
session = self.sessions.get(session_name=session_name)
545+
if session is None:
546+
msg = "Session lookup failed after has_session passed"
547+
raise exc.LibTmuxException(msg)
548+
return session
549+
550+
# Session doesn't exist, create it
551+
# Save and clear TMUX env var (same as new_session)
552+
env = os.environ.get("TMUX")
553+
if env:
554+
del os.environ["TMUX"]
555+
556+
proc = self.cmd(
557+
"new-session",
558+
"-d",
559+
f"-s{session_name}",
560+
"-P",
561+
"-F#{session_id}",
562+
)
563+
564+
if proc.stderr:
565+
raise exc.LibTmuxException(proc.stderr)
566+
567+
session_id = proc.stdout[0]
568+
569+
# Restore TMUX env var
570+
if env:
571+
os.environ["TMUX"] = env
572+
573+
return Session.from_session_id(
574+
server=self,
575+
session_id=session_id,
576+
)
577+
428578
def new_session(
429579
self,
430580
session_name: str | None = None,
@@ -557,7 +707,7 @@ def new_session(
557707
if environment:
558708
if has_gte_version("3.2"):
559709
for k, v in environment.items():
560-
tmux_args += (f"-e{k}={v}",)
710+
tmux_args += ("-e", f"{k}={v}")
561711
else:
562712
logger.warning(
563713
"Environment flag ignored, tmux 3.2 or newer required.",
@@ -592,14 +742,51 @@ def new_session(
592742
#
593743
# Relations
594744
#
745+
def _get_internal_session_names(self) -> set[str]:
746+
"""Get session names used internally by the engine for management."""
747+
internal_names: set[str] = set(
748+
getattr(self.engine, "internal_session_names", set()),
749+
)
750+
try:
751+
return set(internal_names)
752+
except Exception: # pragma: no cover - defensive
753+
return set()
754+
595755
@property
596756
def sessions(self) -> QueryList[Session]:
597-
"""Sessions contained in server.
757+
"""Sessions contained in server (excluding internal engine sessions).
758+
759+
Internal sessions are used by engines for connection management
760+
(e.g., control mode maintains a persistent connection session).
761+
These are automatically filtered to maintain engine transparency.
762+
763+
For advanced debugging, use the internal :meth:`._sessions_all()` method.
598764
599765
Can be accessed via
600766
:meth:`.sessions.get() <libtmux._internal.query_list.QueryList.get()>` and
601767
:meth:`.sessions.filter() <libtmux._internal.query_list.QueryList.filter()>`
602768
"""
769+
all_sessions = self._sessions_all()
770+
771+
# Filter out internal engine sessions
772+
internal_names = self._get_internal_session_names()
773+
filtered_sessions = [
774+
s for s in all_sessions if s.session_name not in internal_names
775+
]
776+
777+
return QueryList(filtered_sessions)
778+
779+
def _sessions_all(self) -> QueryList[Session]:
780+
"""Return all sessions including internal engine sessions.
781+
782+
Used internally for engine management and advanced debugging.
783+
Most users should use the :attr:`.sessions` property instead.
784+
785+
Returns
786+
-------
787+
QueryList[Session]
788+
All sessions including internal ones used by engines.
789+
"""
603790
sessions: list[Session] = []
604791

605792
try:

0 commit comments

Comments
 (0)