Skip to content

Commit 212e65b

Browse files
committed
ControlMode(core): Normalize output, probe safely, and hide control client
why: Align control-mode behaviour with subprocess engine and eliminate bootstrap side effects. what: - Trim trailing blank lines in control protocol results and treat kill-server EOF as success - Add non-bootstrapping liveness probe for control engine; use it in is_alive/raise_if_dead - Filter control client sessions from attached_sessions and add shared server-arg builder - Update regressions: mark passing cases as normal tests
1 parent acf886e commit 212e65b

File tree

3 files changed

+74
-37
lines changed

3 files changed

+74
-37
lines changed

src/libtmux/_internal/engines/control_protocol.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
logger = logging.getLogger(__name__)
2424

2525

26+
def _trim_lines(lines: list[str]) -> list[str]:
27+
"""Remove trailing empty strings to mirror subprocess behaviour."""
28+
trimmed = list(lines)
29+
while trimmed and trimmed[-1] == "":
30+
trimmed.pop()
31+
return trimmed
32+
33+
2634
@dataclasses.dataclass
2735
class CommandContext:
2836
"""Tracks state for a single in-flight control-mode command."""
@@ -273,8 +281,15 @@ def mark_dead(self, reason: str) -> None:
273281
err = exc.ControlModeConnectionError(reason)
274282

275283
if self._current:
276-
self._current.error = err
277-
self._current.signal_done()
284+
# Special-case kill-server: tmux will close the control socket
285+
# immediately after executing the command. Treat that as success.
286+
if "kill-server" in self._current.argv:
287+
self._current.exit_status = ExitStatus.OK
288+
self._current.end_time = time.monotonic()
289+
self._current.signal_done()
290+
else:
291+
self._current.error = err
292+
self._current.signal_done()
278293
self._current = None
279294

280295
while self._pending:
@@ -311,8 +326,8 @@ def build_result(self, ctx: CommandContext) -> CommandResult:
311326
exit_status = ctx.exit_status or ExitStatus.OK
312327
return CommandResult(
313328
argv=ctx.argv,
314-
stdout=ctx.stdout,
315-
stderr=ctx.stderr,
329+
stdout=_trim_lines(ctx.stdout),
330+
stderr=_trim_lines(ctx.stderr),
316331
exit_status=exit_status,
317332
cmd_id=ctx.cmd_id,
318333
start_time=ctx.start_time,

src/libtmux/server.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ def is_alive(self) -> bool:
201201
>>> tmux = Server(socket_name="no_exist")
202202
>>> assert not tmux.is_alive()
203203
"""
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+
204210
try:
205211
res = self.cmd("list-sessions")
206212
except Exception:
@@ -217,18 +223,23 @@ def raise_if_dead(self) -> None:
217223
... print(type(e))
218224
<class 'subprocess.CalledProcessError'>
219225
"""
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+
220238
tmux_bin = shutil.which("tmux")
221239
if tmux_bin is None:
222240
raise exc.TmuxCommandNotFound
223241

224-
server_args: list[str] = []
225-
if self.socket_name:
226-
server_args.append(f"-L{self.socket_name}")
227-
if self.socket_path:
228-
server_args.append(f"-S{self.socket_path}")
229-
if self.config_file:
230-
server_args.append(f"-f{self.config_file}")
231-
242+
server_args = self._build_server_args()
232243
proc = self.engine.run("list-sessions", server_args=server_args)
233244
if proc.returncode is not None and proc.returncode != 0:
234245
raise subprocess.CalledProcessError(
@@ -239,6 +250,30 @@ def raise_if_dead(self) -> None:
239250
#
240251
# Command
241252
#
253+
def _build_server_args(self) -> list[str]:
254+
"""Return tmux server args based on socket/config settings."""
255+
server_args: list[str] = []
256+
if self.socket_name:
257+
server_args.append(f"-L{self.socket_name}")
258+
if self.socket_path:
259+
server_args.append(f"-S{self.socket_path}")
260+
if self.config_file:
261+
server_args.append(f"-f{self.config_file}")
262+
return server_args
263+
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
276+
242277
def cmd(
243278
self,
244279
cmd: str,
@@ -324,7 +359,18 @@ def attached_sessions(self) -> list[Session]:
324359
-------
325360
list of :class:`Session`
326361
"""
327-
return self.sessions.filter(session_attached__noeq="1")
362+
sessions = list(self.sessions.filter(session_attached__noeq="1"))
363+
364+
# Hide internal control-mode client attachment.
365+
from libtmux._internal.engines.control_mode import ControlModeEngine
366+
367+
if isinstance(self.engine, ControlModeEngine):
368+
internal_names = self._get_internal_session_names()
369+
if self.engine._attach_to:
370+
internal_names.add(self.engine._attach_to)
371+
sessions = [s for s in sessions if s.session_name not in internal_names]
372+
373+
return sessions
328374

329375
def has_session(self, target_session: str, exact: bool = True) -> bool:
330376
"""Return True if session exists (excluding internal engine sessions).

tests/test_control_mode_regressions.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,6 @@ class TrailingOutputFixture(t.NamedTuple):
4444
expected_stdout=["line1"],
4545
),
4646
id="one_blank",
47-
marks=pytest.mark.xfail(
48-
reason="control-mode preserves trailing blank stdout lines",
49-
strict=True,
50-
),
5147
),
5248
pytest.param(
5349
TrailingOutputFixture(
@@ -56,10 +52,6 @@ class TrailingOutputFixture(t.NamedTuple):
5652
expected_stdout=["line1"],
5753
),
5854
id="many_blanks",
59-
marks=pytest.mark.xfail(
60-
reason="control-mode preserves trailing blank stdout lines",
61-
strict=True,
62-
),
6355
),
6456
]
6557

@@ -85,10 +77,6 @@ def test_control_protocol_trims_trailing_blank_lines(
8577
assert result.stdout == case.expected_stdout
8678

8779

88-
@pytest.mark.xfail(
89-
reason="kill-server EOF currently surfaces as ControlModeConnectionError",
90-
strict=True,
91-
)
9280
def test_kill_server_eof_marks_success() -> None:
9381
"""EOF during kill-server should be treated as a successful completion."""
9482
proto = ControlProtocol()
@@ -104,10 +92,6 @@ def test_kill_server_eof_marks_success() -> None:
10492
assert result.exit_status is ExitStatus.OK
10593

10694

107-
@pytest.mark.xfail(
108-
reason="control-mode bootstrap currently makes is_alive start a server",
109-
strict=True,
110-
)
11195
def test_is_alive_does_not_bootstrap_control_mode() -> None:
11296
"""is_alive should not spin up control-mode process for an unknown socket."""
11397
socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}"
@@ -385,10 +369,6 @@ def test_capture_pane_variants(case: CapturePaneFixture) -> None:
385369
server.kill()
386370

387371

388-
@pytest.mark.xfail(
389-
reason="control-mode is_alive bootstrap hides missing-server errors",
390-
strict=True,
391-
)
392372
def test_raise_if_dead_raises_on_missing_server() -> None:
393373
"""raise_if_dead should raise when tmux server does not exist."""
394374
socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}"
@@ -399,10 +379,6 @@ def test_raise_if_dead_raises_on_missing_server() -> None:
399379
server.raise_if_dead()
400380

401381

402-
@pytest.mark.xfail(
403-
reason="TestServer with control engine starts server eagerly",
404-
strict=True,
405-
)
406382
def test_testserver_is_alive_false_before_use() -> None:
407383
"""TestServer should report not alive before first use."""
408384
engine = ControlModeEngine()

0 commit comments

Comments
 (0)