Skip to content

Commit 6553a4e

Browse files
committed
ControlMode(test): Add restart, overflow, attach failure, capture range, notifications
1 parent 1ca49e4 commit 6553a4e

File tree

3 files changed

+250
-1
lines changed

3 files changed

+250
-1
lines changed

tests/test_control_client_logs.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,26 @@ def test_control_client_capture_stream_parses(
7474
assert display_ctx.done.wait(timeout=0.5)
7575
result = proto.build_result(display_ctx)
7676
assert "hello" in result.stdout or "hello" in "".join(result.stdout)
77+
78+
79+
def test_control_client_notification_parsing(
80+
control_client_logs: tuple[t.Any, ControlProtocol],
81+
) -> None:
82+
"""Control client log stream should produce notifications."""
83+
proc, proto = control_client_logs
84+
assert proc.stdin is not None
85+
86+
ctx = CommandContext(argv=["tmux", "display-message", "-p", "ping"])
87+
proto.register_command(ctx)
88+
# send a trivial command and rely on session-changed notification from attach
89+
proc.stdin.write("display-message -p ping\n")
90+
proc.stdin.write("detach-client\n")
91+
proc.stdin.flush()
92+
93+
stdout_data, _ = proc.communicate(timeout=5)
94+
for line in stdout_data.splitlines():
95+
proto.feed_line(line.rstrip("\n"))
96+
97+
notif = proto.get_notification(timeout=0.1)
98+
assert notif is not None
99+
assert notif.kind.name in {"SESSION_CHANGED", "CLIENT_SESSION_CHANGED", "RAW"}

tests/test_control_mode_engine.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from libtmux import exc
1414
from libtmux._internal.engines.base import ExitStatus
1515
from libtmux._internal.engines.control_mode import ControlModeEngine
16+
from libtmux._internal.engines.control_protocol import ControlProtocol
1617
from libtmux.server import Server
1718

1819

@@ -112,6 +113,40 @@ def fake_start(server_args: t.Sequence[str | int] | None) -> None:
112113
assert engine.process is None
113114

114115

116+
def test_control_mode_per_command_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
117+
"""Per-call timeout should close process and raise ControlModeTimeout."""
118+
119+
class FakeProcess:
120+
def __init__(self) -> None:
121+
self.stdin = io.StringIO()
122+
self.stdout: t.Iterator[str] = iter([]) # no output
123+
self.stderr = None
124+
self._terminated = False
125+
126+
def terminate(self) -> None:
127+
self._terminated = True
128+
129+
def kill(self) -> None:
130+
self._terminated = True
131+
132+
def wait(self, timeout: float | None = None) -> None:
133+
return None
134+
135+
engine = ControlModeEngine(command_timeout=5.0)
136+
137+
def fake_start(server_args: t.Sequence[str | int] | None) -> None:
138+
engine.tmux_bin = "tmux"
139+
engine._server_args = tuple(server_args or ())
140+
engine.process = t.cast(subprocess.Popen[str], FakeProcess())
141+
142+
monkeypatch.setattr(engine, "_start_process", fake_start)
143+
144+
with pytest.raises(exc.ControlModeTimeout):
145+
engine.run("list-sessions", timeout=0.01)
146+
147+
assert engine.process is None
148+
149+
115150
def test_control_mode_custom_session_name(tmp_path: pathlib.Path) -> None:
116151
"""Control mode engine can use custom internal session name."""
117152
socket_path = tmp_path / "tmux-custom-session-test"
@@ -173,3 +208,83 @@ def test_control_mode_attach_to_existing(tmp_path: pathlib.Path) -> None:
173208
server2.kill()
174209
assert control_engine.process is not None
175210
control_engine.process.wait(timeout=2)
211+
212+
213+
class RestartFixture(t.NamedTuple):
214+
"""Fixture for restart/broken-pipe handling."""
215+
216+
test_id: str
217+
should_raise: type[BaseException]
218+
219+
220+
@pytest.mark.parametrize(
221+
"case",
222+
[
223+
RestartFixture(
224+
test_id="broken_pipe_increments_restart",
225+
should_raise=exc.ControlModeConnectionError,
226+
),
227+
],
228+
ids=lambda c: c.test_id,
229+
)
230+
def test_write_line_broken_pipe_increments_restart(
231+
case: RestartFixture,
232+
) -> None:
233+
"""Broken pipe should raise ControlModeConnectionError and bump restarts."""
234+
235+
class FakeStdin:
236+
def write(self, _: str) -> None:
237+
raise BrokenPipeError
238+
239+
def flush(self) -> None: # pragma: no cover - not reached
240+
return None
241+
242+
class FakeProcess:
243+
def __init__(self) -> None:
244+
self.stdin = FakeStdin()
245+
246+
engine = ControlModeEngine()
247+
engine.process = FakeProcess() # type: ignore[assignment]
248+
249+
with pytest.raises(case.should_raise):
250+
engine._write_line("list-sessions", server_args=())
251+
assert engine._restarts == 1
252+
assert engine.process is None
253+
254+
255+
class NotificationOverflowFixture(t.NamedTuple):
256+
"""Fixture for notification overflow handling."""
257+
258+
test_id: str
259+
queue_size: int
260+
overflow: int
261+
262+
263+
@pytest.mark.parametrize(
264+
"case",
265+
[
266+
NotificationOverflowFixture(
267+
test_id="iter_notifications_after_drop",
268+
queue_size=1,
269+
overflow=3,
270+
),
271+
],
272+
ids=lambda c: c.test_id,
273+
)
274+
def test_iter_notifications_survives_overflow(
275+
case: NotificationOverflowFixture,
276+
) -> None:
277+
"""iter_notifications should continue yielding after queue drops."""
278+
engine = ControlModeEngine()
279+
engine._protocol = ControlProtocol(notification_queue_size=case.queue_size)
280+
281+
for _ in range(case.overflow):
282+
engine._protocol.feed_line("%sessions-changed")
283+
284+
stats = engine.get_stats()
285+
assert stats.dropped_notifications >= case.overflow - case.queue_size
286+
287+
notif_iter = engine.iter_notifications(timeout=0.01)
288+
first = next(notif_iter, None)
289+
assert first is not None
290+
assert first.kind.name == "SESSIONS_CHANGED"

tests/test_control_mode_regressions.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class AttachFixture(t.NamedTuple):
3636

3737
test_id: str
3838
attach_to: str
39+
expect_attached: bool
3940

4041

4142
TRAILING_OUTPUT_CASES = [
@@ -573,11 +574,116 @@ def test_session_kill_handles_control_eof() -> None:
573574
server.kill()
574575

575576

577+
class CaptureRangeFixture(t.NamedTuple):
578+
"""Fixture for capture-pane range/flag behavior."""
579+
580+
test_id: str
581+
start: int | None
582+
end: int | None
583+
expected_tail: str
584+
585+
586+
class InternalNameCollisionFixture(t.NamedTuple):
587+
"""Fixture for internal session name collisions."""
588+
589+
test_id: str
590+
internal_name: str
591+
592+
593+
@pytest.mark.engines(["control"])
594+
@pytest.mark.parametrize(
595+
"case",
596+
[
597+
CaptureRangeFixture(
598+
test_id="capture_with_range_untrimmed",
599+
start=-1,
600+
end=-1,
601+
expected_tail="line2",
602+
),
603+
],
604+
ids=lambda c: c.test_id,
605+
)
606+
def test_capture_pane_respects_range(case: CaptureRangeFixture) -> None:
607+
"""capture-pane with explicit range should return requested lines."""
608+
socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}"
609+
engine = ControlModeEngine()
610+
server = Server(socket_name=socket_name, engine=engine)
611+
try:
612+
session = server.new_session(
613+
session_name="capture_range",
614+
attach=False,
615+
kill_session=True,
616+
)
617+
pane = session.active_pane
618+
assert pane is not None
619+
pane.send_keys(
620+
'printf "line1\\nline2\\n"',
621+
literal=True,
622+
suppress_history=False,
623+
)
624+
lines = pane.capture_pane(start=case.start, end=case.end)
625+
assert lines
626+
assert lines[-1].strip() == case.expected_tail
627+
finally:
628+
with contextlib.suppress(Exception):
629+
server.kill()
630+
631+
576632
@pytest.mark.engines(["control"])
577633
@pytest.mark.parametrize(
578634
"case",
579635
[
580-
AttachFixture(test_id="attach_existing", attach_to="shared_session"),
636+
pytest.param(
637+
InternalNameCollisionFixture(
638+
test_id="collision_same_name",
639+
internal_name="libtmux_control_mode",
640+
),
641+
marks=pytest.mark.xfail(
642+
reason="Engine does not yet guard internal session name collisions",
643+
),
644+
),
645+
],
646+
ids=lambda c: c.test_id,
647+
)
648+
def test_internal_session_name_collision(case: InternalNameCollisionFixture) -> None:
649+
"""Two control engines with same internal name should not mask user sessions."""
650+
socket_one = f"libtmux_test_{uuid.uuid4().hex[:8]}"
651+
socket_two = f"libtmux_test_{uuid.uuid4().hex[:8]}"
652+
engine1 = ControlModeEngine(internal_session_name=case.internal_name)
653+
engine2 = ControlModeEngine(internal_session_name=case.internal_name)
654+
server1 = Server(socket_name=socket_one, engine=engine1)
655+
server2 = Server(socket_name=socket_two, engine=engine2)
656+
657+
try:
658+
server1.new_session(session_name="user_one", attach=False, kill_session=True)
659+
server2.new_session(session_name="user_two", attach=False, kill_session=True)
660+
661+
assert any(s.session_name == "user_one" for s in server1.sessions)
662+
assert any(s.session_name == "user_two" for s in server2.sessions)
663+
finally:
664+
with contextlib.suppress(Exception):
665+
server1.kill()
666+
with contextlib.suppress(Exception):
667+
server2.kill()
668+
669+
670+
@pytest.mark.engines(["control"])
671+
@pytest.mark.parametrize(
672+
"case",
673+
[
674+
AttachFixture(
675+
test_id="attach_existing",
676+
attach_to="shared_session",
677+
expect_attached=True,
678+
),
679+
pytest.param(
680+
AttachFixture(
681+
test_id="attach_missing",
682+
attach_to="missing_session",
683+
expect_attached=False,
684+
),
685+
id="attach_missing_session",
686+
),
581687
],
582688
ids=lambda c: c.test_id,
583689
)
@@ -594,6 +700,11 @@ def test_attach_to_existing_session(case: AttachFixture) -> None:
594700
)
595701
engine = ControlModeEngine(attach_to=case.attach_to)
596702
server = Server(socket_name=socket_name, engine=engine)
703+
if not case.expect_attached:
704+
with pytest.raises(exc.ControlModeConnectionError):
705+
_ = server.sessions
706+
return
707+
597708
sessions = server.sessions
598709
assert len(sessions) == 1
599710
assert sessions[0].session_name == case.attach_to

0 commit comments

Comments
 (0)