Skip to content

Commit 723774b

Browse files
committed
feat: implement robust ControlModeEngine with startup session
1 parent 13a7aed commit 723774b

File tree

2 files changed

+84
-4
lines changed

2 files changed

+84
-4
lines changed

src/libtmux/_internal/engines/control_mode.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import shlex
67
import shutil
78
import subprocess
89
import threading
@@ -44,6 +45,7 @@ def _start_process(self, server_args: t.Sequence[str | int] | None) -> None:
4445
if server_args:
4546
cmd.extend(str(a) for a in server_args)
4647
cmd.append("-C")
48+
cmd.extend(["new-session", "-A", "-s", "libtmux_control_mode"])
4749

4850
logger.debug(f"Starting Control Mode process: {cmd}")
4951
self.process = subprocess.Popen(
@@ -57,6 +59,17 @@ def _start_process(self, server_args: t.Sequence[str | int] | None) -> None:
5759
)
5860
self._server_args = server_args
5961

62+
# Consume startup command output
63+
assert self.process.stdout is not None
64+
while True:
65+
line = self.process.stdout.readline()
66+
if not line:
67+
# EOF immediately?
68+
logger.warning("Control Mode process exited immediately")
69+
break
70+
if line.startswith("%end") or line.startswith("%error"):
71+
break
72+
6073
def run(
6174
self,
6275
cmd: str,
@@ -82,13 +95,13 @@ def run(
8295
assert self.process.stdout is not None
8396

8497
# 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.
98+
# We use shlex.join for correct shell-like quoting, required by tmux control
99+
# mode.
87100
full_args = [cmd]
88101
if cmd_args:
89102
full_args.extend(str(a) for a in cmd_args)
90103

91-
command_line = subprocess.list2cmdline(full_args)
104+
command_line = shlex.join(full_args)
92105

93106
logger.debug(f"Sending to Control Mode: {command_line}")
94107
try:
@@ -122,10 +135,21 @@ def run(
122135

123136
if line.startswith("%begin"):
124137
# Start of response
138+
# %begin time id flags
139+
parts = line.split()
140+
if len(parts) > 3:
141+
flags = int(parts[3])
142+
if flags & 1:
143+
returncode = 1
125144
continue
126145
elif line.startswith("%end"):
127146
# End of success response
128-
returncode = 0
147+
# %end time id flags
148+
parts = line.split()
149+
if len(parts) > 3:
150+
flags = int(parts[3])
151+
if flags & 1:
152+
returncode = 1
129153
break
130154
elif line.startswith("%error"):
131155
# End of error response
@@ -144,6 +168,10 @@ def run(
144168
# Tmux usually puts error message in stdout (captured above) for %error
145169
# But we moved it to stderr_lines if %error occurred.
146170

171+
# If we detected failure via flags but got %end, treat stdout as potentially
172+
# containing info?
173+
# For now, keep stdout as is.
174+
147175
# Mimic subprocess.communicate output structure
148176
return tmux_cmd(
149177
cmd=[cmd] + (list(map(str, cmd_args)) if cmd_args else []),

tests/test_control_mode_engine.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Tests for ControlModeEngine."""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
7+
from libtmux._internal.engines.control_mode import ControlModeEngine
8+
from libtmux.server import Server
9+
10+
11+
def test_control_mode_engine_basic(tmp_path: pathlib.Path) -> None:
12+
"""Test basic functionality of ControlModeEngine."""
13+
socket_path = tmp_path / "tmux-control-mode-test"
14+
engine = ControlModeEngine()
15+
16+
# Server should auto-start engine on first cmd
17+
server = Server(socket_path=socket_path, engine=engine)
18+
19+
# kill server if exists (cleanup from previous runs if any)
20+
if server.is_alive():
21+
server.kill()
22+
23+
# new session
24+
session = server.new_session(session_name="test_sess", kill_session=True)
25+
assert session.name == "test_sess"
26+
27+
# check engine process is running
28+
assert engine.process is not None
29+
assert engine.process.poll() is None
30+
31+
# list sessions
32+
# ControlModeEngine creates a bootstrap session "libtmux_control_mode", so we
33+
# expect 2 sessions
34+
sessions = server.sessions
35+
assert len(sessions) >= 1
36+
session_names = [s.name for s in sessions]
37+
assert "test_sess" in session_names
38+
assert "libtmux_control_mode" in session_names
39+
40+
# run a command that returns output
41+
output = server.cmd("display-message", "-p", "hello").stdout
42+
assert output == ["hello"]
43+
44+
# cleanup
45+
server.kill()
46+
# Engine process should terminate eventually (ControlModeEngine.close is called
47+
# manually or via weakref/del)
48+
# Server.kill() kills the tmux SERVER. The control mode client process should
49+
# exit as a result.
50+
51+
engine.process.wait(timeout=2)
52+
assert engine.process.poll() is not None

0 commit comments

Comments
 (0)