Skip to content

Commit 23592e0

Browse files
committed
Test(fix[control-mode]): Bound the wait off the descriptor
why: The test asked select whether the descriptor had a line and then read with readline, which serves from the buffer above it. ControlMode runs its subprocess with text=True, and tmux writes a whole %begin/%end block in one burst, so the first readline routinely drains every remaining line off the descriptor -- select then reports nothing ready while the answer is already in hand. Passing at all depended on unrelated %output from the pane's shell re-arming select. Closes #731. what: - Read lines on a thread and poll a queue with a monotonic deadline, so the wait is bounded without consulting the descriptor - Explain in the helper why the descriptor cannot answer the question - Leave ControlMode alone: text=True and encoding="utf-8" are the behaviour this test guards
1 parent be7c8c1 commit 23592e0

2 files changed

Lines changed: 67 additions & 6 deletions

File tree

CHANGES

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ it.
5959

6060
### Development
6161

62+
#### The control-mode test no longer flakes in CI (#733)
63+
64+
`test_control_mode_stdout_preserves_non_ascii_output` intermittently timed out
65+
waiting for output that had already arrived, so it failed on loaded runners and
66+
passed on re-run. The test now bounds its wait with a reader thread and a
67+
deadline. The non-ASCII decoding regression it guards is still covered.
68+
6269
#### CI actions updated to current majors
6370

6471
Workflow actions moved to their current major releases: `actions/checkout` v7,

tests/test_control_mode.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
import locale
66
import os
7-
import select
7+
import queue
88
import sys
9+
import threading
10+
import time
911
import typing as t
1012

1113
import pytest
@@ -14,9 +16,65 @@
1416
from libtmux.formats import FORMAT_SEPARATOR
1517

1618
if t.TYPE_CHECKING:
19+
from collections.abc import Iterator
20+
1721
from libtmux.server import Server
1822

1923

24+
def _read_lines(
25+
stream: t.IO[str],
26+
*,
27+
limit: int,
28+
timeout: float,
29+
) -> Iterator[str]:
30+
"""Yield up to *limit* lines from *stream*, giving up after *timeout*.
31+
32+
A reader thread owns the stream and the caller polls a queue, so the wait
33+
is bounded without anything having to ask the file descriptor whether a
34+
line is available.
35+
36+
That question has no useful answer here. ``ControlMode`` builds its
37+
subprocess with ``text=True``, so ``stream`` is a ``TextIOWrapper`` over a
38+
``BufferedReader``: ``select`` would report readiness on the raw
39+
descriptor while ``readline`` serves from the userspace buffer above it.
40+
tmux writes a whole ``%begin``/``%end`` block in one burst, so the first
41+
``readline`` routinely drains every remaining line off the descriptor --
42+
leaving ``select`` with nothing to report and the answer already in hand.
43+
"""
44+
lines: queue.Queue[str | BaseException | None] = queue.Queue()
45+
46+
def pump() -> None:
47+
try:
48+
for line in stream:
49+
lines.put(line)
50+
except BaseException as e: # noqa: BLE001
51+
# Carry it across the thread boundary. Collapsing it into the
52+
# ``None`` sentinel would report the reader as having reached EOF,
53+
# naming the wrong cause for a decode error this test exists to
54+
# catch.
55+
lines.put(e)
56+
finally:
57+
lines.put(None)
58+
59+
reader = threading.Thread(target=pump, daemon=True)
60+
reader.start()
61+
62+
deadline = time.monotonic() + timeout
63+
for _ in range(limit):
64+
remaining = deadline - time.monotonic()
65+
if remaining <= 0:
66+
pytest.fail("timed out waiting for control-mode output")
67+
try:
68+
line = lines.get(timeout=remaining)
69+
except queue.Empty:
70+
pytest.fail("timed out waiting for control-mode output")
71+
if isinstance(line, BaseException):
72+
raise line
73+
if line is None:
74+
pytest.fail("control-mode stream closed before the expected output")
75+
yield line
76+
77+
2078
def test_control_mode_creates_client(
2179
control_mode: t.Callable[[], ControlMode],
2280
server: Server,
@@ -86,11 +144,7 @@ def test_control_mode_stdout_preserves_non_ascii_output(
86144
f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(),
87145
)
88146

89-
for _ in range(20):
90-
ready, _, _ = select.select([ctl.stdout], [], [], 1)
91-
assert ready, "timed out waiting for control-mode output"
92-
93-
line = ctl.stdout.readline()
147+
for line in _read_lines(ctl.stdout, limit=20, timeout=5):
94148
if FORMAT_SEPARATOR in line:
95149
break
96150
else:

0 commit comments

Comments
 (0)