Skip to content

Commit 13a7aed

Browse files
committed
feat: Implement ControlModeEngine and refactor Server.cmd
1 parent 79e5573 commit 13a7aed

File tree

4 files changed

+192
-25
lines changed

4 files changed

+192
-25
lines changed

src/libtmux/_internal/engines/base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ class Engine(ABC):
1313
"""Abstract base class for tmux execution engines."""
1414

1515
@abstractmethod
16-
def run(self, *args: t.Any) -> tmux_cmd:
16+
def run(
17+
self,
18+
cmd: str,
19+
cmd_args: t.Sequence[str | int] | None = None,
20+
server_args: t.Sequence[str | int] | None = None,
21+
) -> tmux_cmd:
1722
"""Run a tmux command and return the result."""
1823
...
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Control Mode engine for libtmux."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import shutil
7+
import subprocess
8+
import threading
9+
import typing as t
10+
11+
from libtmux import exc
12+
from libtmux._internal.engines.base import Engine
13+
from libtmux.common import tmux_cmd
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class ControlModeEngine(Engine):
19+
"""Engine that runs tmux commands via a persistent Control Mode process."""
20+
21+
def __init__(self) -> None:
22+
self.process: subprocess.Popen[str] | None = None
23+
self._lock = threading.Lock()
24+
self._server_args: t.Sequence[str | int] | None = None
25+
26+
def close(self) -> None:
27+
"""Terminate the tmux control mode process."""
28+
if self.process:
29+
self.process.terminate()
30+
self.process.wait()
31+
self.process = None
32+
33+
def __del__(self) -> None:
34+
"""Cleanup the process on destruction."""
35+
self.close()
36+
37+
def _start_process(self, server_args: t.Sequence[str | int] | None) -> None:
38+
"""Start the tmux control mode process."""
39+
tmux_bin = shutil.which("tmux")
40+
if not tmux_bin:
41+
raise exc.TmuxCommandNotFound
42+
43+
cmd = [tmux_bin]
44+
if server_args:
45+
cmd.extend(str(a) for a in server_args)
46+
cmd.append("-C")
47+
48+
logger.debug(f"Starting Control Mode process: {cmd}")
49+
self.process = subprocess.Popen(
50+
cmd,
51+
stdin=subprocess.PIPE,
52+
stdout=subprocess.PIPE,
53+
stderr=subprocess.PIPE,
54+
text=True,
55+
bufsize=0, # Unbuffered
56+
errors="backslashreplace",
57+
)
58+
self._server_args = server_args
59+
60+
def run(
61+
self,
62+
cmd: str,
63+
cmd_args: t.Sequence[str | int] | None = None,
64+
server_args: t.Sequence[str | int] | None = None,
65+
) -> tmux_cmd:
66+
"""Run a tmux command via Control Mode."""
67+
with self._lock:
68+
if self.process is None:
69+
self._start_process(server_args)
70+
elif server_args != self._server_args:
71+
# If server_args changed, we might need a new process.
72+
# For now, just warn or restart. Restarting is safer.
73+
logger.warning(
74+
"Server args changed, restarting Control Mode process. "
75+
f"Old: {self._server_args}, New: {server_args}"
76+
)
77+
self.close()
78+
self._start_process(server_args)
79+
80+
assert self.process is not None
81+
assert self.process.stdin is not None
82+
assert self.process.stdout is not None
83+
84+
# Construct the command line
85+
# We use subprocess.list2cmdline for basic quoting, but we need to be
86+
# careful. tmux control mode accepts a single line.
87+
full_args = [cmd]
88+
if cmd_args:
89+
full_args.extend(str(a) for a in cmd_args)
90+
91+
command_line = subprocess.list2cmdline(full_args)
92+
93+
logger.debug(f"Sending to Control Mode: {command_line}")
94+
try:
95+
self.process.stdin.write(command_line + "\n")
96+
self.process.stdin.flush()
97+
except BrokenPipeError:
98+
# Process died?
99+
logger.exception("Control Mode process died, restarting...")
100+
self.close()
101+
self._start_process(server_args)
102+
assert self.process is not None
103+
assert self.process.stdin is not None
104+
assert self.process.stdout is not None
105+
self.process.stdin.write(command_line + "\n")
106+
self.process.stdin.flush()
107+
108+
# Read response
109+
stdout_lines: list[str] = []
110+
stderr_lines: list[str] = []
111+
returncode = 0
112+
113+
while True:
114+
line = self.process.stdout.readline()
115+
if not line:
116+
# EOF
117+
logger.error("Unexpected EOF from Control Mode process")
118+
returncode = 1
119+
break
120+
121+
line = line.rstrip("\n")
122+
123+
if line.startswith("%begin"):
124+
# Start of response
125+
continue
126+
elif line.startswith("%end"):
127+
# End of success response
128+
returncode = 0
129+
break
130+
elif line.startswith("%error"):
131+
# End of error response
132+
returncode = 1
133+
# Captured lines are the error message
134+
stderr_lines = stdout_lines
135+
stdout_lines = []
136+
break
137+
elif line.startswith("%"):
138+
# Notification (ignore for now)
139+
logger.debug(f"Control Mode Notification: {line}")
140+
continue
141+
else:
142+
stdout_lines.append(line)
143+
144+
# Tmux usually puts error message in stdout (captured above) for %error
145+
# But we moved it to stderr_lines if %error occurred.
146+
147+
# Mimic subprocess.communicate output structure
148+
return tmux_cmd(
149+
cmd=[cmd] + (list(map(str, cmd_args)) if cmd_args else []),
150+
stdout=stdout_lines,
151+
stderr=stderr_lines,
152+
returncode=returncode,
153+
)

src/libtmux/_internal/engines/subprocess_engine.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,29 @@
1717
class SubprocessEngine(Engine):
1818
"""Engine that runs tmux commands via subprocess."""
1919

20-
def run(self, *args: t.Any) -> tmux_cmd:
20+
def run(
21+
self,
22+
cmd: str,
23+
cmd_args: t.Sequence[str | int] | None = None,
24+
server_args: t.Sequence[str | int] | None = None,
25+
) -> tmux_cmd:
2126
"""Run a tmux command using subprocess.Popen."""
2227
tmux_bin = shutil.which("tmux")
2328
if not tmux_bin:
2429
raise exc.TmuxCommandNotFound
2530

26-
cmd = [tmux_bin]
27-
cmd += args # add the command arguments to cmd
28-
cmd = [str(c) for c in cmd]
31+
full_cmd: list[str | int] = [tmux_bin]
32+
if server_args:
33+
full_cmd += list(server_args)
34+
full_cmd.append(cmd)
35+
if cmd_args:
36+
full_cmd += list(cmd_args)
37+
38+
full_cmd_str = [str(c) for c in full_cmd]
2939

3040
try:
3141
process = subprocess.Popen(
32-
cmd,
42+
full_cmd_str,
3343
stdout=subprocess.PIPE,
3444
stderr=subprocess.PIPE,
3545
text=True,
@@ -38,7 +48,7 @@ def run(self, *args: t.Any) -> tmux_cmd:
3848
stdout_str, stderr_str = process.communicate()
3949
returncode = process.returncode
4050
except Exception:
41-
logger.exception(f"Exception for {subprocess.list2cmdline(cmd)}")
51+
logger.exception(f"Exception for {subprocess.list2cmdline(full_cmd_str)}")
4252
raise
4353

4454
stdout_split = stdout_str.split("\n")
@@ -49,20 +59,20 @@ def run(self, *args: t.Any) -> tmux_cmd:
4959
stderr_split = stderr_str.split("\n")
5060
stderr = list(filter(None, stderr_split)) # filter empty values
5161

52-
if "has-session" in cmd and len(stderr) and not stdout_split:
62+
if "has-session" in full_cmd_str and len(stderr) and not stdout_split:
5363
stdout = [stderr[0]]
5464
else:
5565
stdout = stdout_split
5666

5767
logger.debug(
5868
"self.stdout for {cmd}: {stdout}".format(
59-
cmd=" ".join(cmd),
69+
cmd=" ".join(full_cmd_str),
6070
stdout=stdout,
6171
),
6272
)
6373

6474
return tmux_cmd(
65-
cmd=cmd,
75+
cmd=full_cmd_str,
6676
stdout=stdout,
6777
stderr=stderr,
6878
returncode=returncode,

src/libtmux/server.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -221,19 +221,19 @@ def raise_if_dead(self) -> None:
221221
if tmux_bin is None:
222222
raise exc.TmuxCommandNotFound
223223

224-
cmd_args: list[str] = ["list-sessions"]
224+
server_args: list[str] = []
225225
if self.socket_name:
226-
cmd_args.insert(0, f"-L{self.socket_name}")
226+
server_args.append(f"-L{self.socket_name}")
227227
if self.socket_path:
228-
cmd_args.insert(0, f"-S{self.socket_path}")
228+
server_args.append(f"-S{self.socket_path}")
229229
if self.config_file:
230-
cmd_args.insert(0, f"-f{self.config_file}")
230+
server_args.append(f"-f{self.config_file}")
231231

232-
proc = self.engine.run(*cmd_args)
232+
proc = self.engine.run("list-sessions", server_args=server_args)
233233
if proc.returncode is not None and proc.returncode != 0:
234234
raise subprocess.CalledProcessError(
235235
returncode=proc.returncode,
236-
cmd=[tmux_bin, *cmd_args],
236+
cmd=[tmux_bin, *server_args, "list-sessions"],
237237
)
238238

239239
#
@@ -292,25 +292,24 @@ def cmd(
292292
293293
Renamed from ``.tmux`` to ``.cmd``.
294294
"""
295-
svr_args: list[str | int] = [cmd]
296-
cmd_args: list[str | int] = []
295+
server_args: list[str | int] = []
297296
if self.socket_name:
298-
svr_args.insert(0, f"-L{self.socket_name}")
297+
server_args.append(f"-L{self.socket_name}")
299298
if self.socket_path:
300-
svr_args.insert(0, f"-S{self.socket_path}")
299+
server_args.append(f"-S{self.socket_path}")
301300
if self.config_file:
302-
svr_args.insert(0, f"-f{self.config_file}")
301+
server_args.append(f"-f{self.config_file}")
303302
if self.colors:
304303
if self.colors == 256:
305-
svr_args.insert(0, "-2")
304+
server_args.append("-2")
306305
elif self.colors == 88:
307-
svr_args.insert(0, "-8")
306+
server_args.append("-8")
308307
else:
309308
raise exc.UnknownColorOption
310309

311-
cmd_args = ["-t", str(target), *args] if target is not None else [*args]
310+
cmd_args = ["-t", str(target), *args] if target is not None else list(args)
312311

313-
return self.engine.run(*svr_args, *cmd_args)
312+
return self.engine.run(cmd, cmd_args=cmd_args, server_args=server_args)
314313

315314
@property
316315
def attached_sessions(self) -> list[Session]:

0 commit comments

Comments
 (0)