From 5945c9261e7cd41f1b412058567f0a464b96c6be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 31 Jul 2026 07:02:11 -0500 Subject: [PATCH 1/2] 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 --- tests/test_control_mode.py | 66 ++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index f72f68466..7de2fb109 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -4,8 +4,10 @@ import locale import os -import select +import queue import sys +import threading +import time import typing as t import pytest @@ -14,9 +16,65 @@ from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: + from collections.abc import Iterator + from libtmux.server import Server +def _read_lines( + stream: t.IO[str], + *, + limit: int, + timeout: float, +) -> Iterator[str]: + """Yield up to *limit* lines from *stream*, giving up after *timeout*. + + A reader thread owns the stream and the caller polls a queue, so the wait + is bounded without anything having to ask the file descriptor whether a + line is available. + + That question has no useful answer here. ``ControlMode`` builds its + subprocess with ``text=True``, so ``stream`` is a ``TextIOWrapper`` over a + ``BufferedReader``: ``select`` would report readiness on the raw + descriptor while ``readline`` serves from the userspace buffer above it. + tmux writes a whole ``%begin``/``%end`` block in one burst, so the first + ``readline`` routinely drains every remaining line off the descriptor -- + leaving ``select`` with nothing to report and the answer already in hand. + """ + lines: queue.Queue[str | BaseException | None] = queue.Queue() + + def pump() -> None: + try: + for line in stream: + lines.put(line) + except BaseException as e: # noqa: BLE001 + # Carry it across the thread boundary. Collapsing it into the + # ``None`` sentinel would report the reader as having reached EOF, + # naming the wrong cause for a decode error this test exists to + # catch. + lines.put(e) + finally: + lines.put(None) + + reader = threading.Thread(target=pump, daemon=True) + reader.start() + + deadline = time.monotonic() + timeout + for _ in range(limit): + remaining = deadline - time.monotonic() + if remaining <= 0: + pytest.fail("timed out waiting for control-mode output") + try: + line = lines.get(timeout=remaining) + except queue.Empty: + pytest.fail("timed out waiting for control-mode output") + if isinstance(line, BaseException): + raise line + if line is None: + pytest.fail("control-mode stream closed before the expected output") + yield line + + def test_control_mode_creates_client( control_mode: t.Callable[[], ControlMode], server: Server, @@ -86,11 +144,7 @@ def test_control_mode_stdout_preserves_non_ascii_output( f"display-message -p '{FORMAT_SEPARATOR}'\n".encode(), ) - for _ in range(20): - ready, _, _ = select.select([ctl.stdout], [], [], 1) - assert ready, "timed out waiting for control-mode output" - - line = ctl.stdout.readline() + for line in _read_lines(ctl.stdout, limit=20, timeout=5): if FORMAT_SEPARATOR in line: break else: From 8c54de10082d323ad2d2cfaea8284609f82b58e3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 1 Aug 2026 05:40:23 -0500 Subject: [PATCH 2/2] docs(CHANGES): Control-mode test flake why: A test that fails on loaded runners and passes on re-run costs every contributor a re-run, so the entry belongs in the release notes even though no library code changed. what: - Record the bounded control-mode wait under `Development`, where a test-suite-only change belongs. --- CHANGES | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES b/CHANGES index 7a691b0cc..14858ff07 100644 --- a/CHANGES +++ b/CHANGES @@ -59,6 +59,13 @@ it. ### Development +#### The control-mode test no longer flakes in CI (#733) + +`test_control_mode_stdout_preserves_non_ascii_output` intermittently timed out +waiting for output that had already arrived, so it failed on loaded runners and +passed on re-run. The test now bounds its wait with a reader thread and a +deadline. The non-ASCII decoding regression it guards is still covered. + #### CI actions updated to current majors Workflow actions moved to their current major releases: `actions/checkout` v7,