diff --git a/CHANGELOG.md b/CHANGELOG.md index 67652d343d..611889a366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Fixed + +- Fixed the input thread busy-looping at 100% CPU when stdin reaches EOF https://github.com/Textualize/textual/pull/6690 + ## [8.2.8] - 2026-06-30 ### Fixed diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 74ed463187..b6b48b88cf 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -431,6 +431,8 @@ def run_input_thread(self) -> None: decode = utf8_decoder read = os.read + eof = False + def process_selector_events( selector_events: list[tuple[selectors.SelectorKey, int]], final: bool = False, @@ -442,11 +444,18 @@ def process_selector_events( final: True if this is the last call. """ + nonlocal eof for last, (_selector_key, mask) in loop_last(selector_events): if mask & EVENT_READ: - unicode_data = decode(read(fileno, 1024 * 4), final=final and last) + raw_data = read(fileno, 1024 * 4) + if not raw_data: + # EOF. A selector reports it as permanently readable, so + # stop selecting or this thread would busy-loop. + eof = True + break + unicode_data = decode(raw_data, final=final and last) if not unicode_data: - # This can occur if the stdin is piped + # Incomplete UTF-8 sequence. break for event in feed(unicode_data): self.process_message(event) @@ -456,6 +465,8 @@ def process_selector_events( try: while not self.exit_event.is_set(): process_selector_events(selector.select(0.1)) + if eof: + break selector.unregister(self.fileno) process_selector_events(selector.select(0.1), final=True) diff --git a/src/textual/drivers/linux_inline_driver.py b/src/textual/drivers/linux_inline_driver.py index 14aa61fba0..0c1209ce80 100644 --- a/src/textual/drivers/linux_inline_driver.py +++ b/src/textual/drivers/linux_inline_driver.py @@ -134,6 +134,8 @@ def run_input_thread(self) -> None: decode = utf8_decoder read = os.read + eof = False + def process_selector_events( selector_events: list[tuple[selectors.SelectorKey, int]], final: bool = False, @@ -145,11 +147,18 @@ def process_selector_events( final: True if this is the last call. """ + nonlocal eof for last, (_selector_key, mask) in loop_last(selector_events): if mask & EVENT_READ: - unicode_data = decode(read(fileno, 1024 * 4), final=final and last) + raw_data = read(fileno, 1024 * 4) + if not raw_data: + # EOF. A selector reports it as permanently readable, so + # stop selecting or this thread would busy-loop. + eof = True + break + unicode_data = decode(raw_data, final=final and last) if not unicode_data: - # This can occur if the stdin is piped + # Incomplete UTF-8 sequence. break for event in feed(unicode_data): if isinstance(event, events.CursorPosition): @@ -165,6 +174,8 @@ def process_selector_events( try: while not self.exit_event.is_set(): process_selector_events(selector.select(0.1)) + if eof: + break selector.unregister(self.fileno) process_selector_events(selector.select(0.1), final=True) diff --git a/tests/test_driver_input_eof.py b/tests/test_driver_input_eof.py new file mode 100644 index 0000000000..fae9c66f87 --- /dev/null +++ b/tests/test_driver_input_eof.py @@ -0,0 +1,73 @@ +"""Regression test for the input thread when stdin reaches end-of-file. + +A selector reports a file descriptor at EOF as readable, so `select()` returns +immediately, forever. Unless the input thread notices the EOF and stops +selecting, it busy-loops and pegs a CPU core for the life of the app. + +Asserting the thread returns is a deterministic stand-in for measuring CPU: a +thread that has returned cannot busy-loop. +""" + +import os +import signal +import sys +import threading +from typing import Iterator + +import pytest + +from textual.app import App +from textual.driver import Driver + +if sys.platform == "win32": + pytest.skip("LinuxDriver/LinuxInlineDriver are POSIX only", allow_module_level=True) + +from textual.drivers.linux_driver import LinuxDriver +from textual.drivers.linux_inline_driver import LinuxInlineDriver + + +@pytest.fixture +def eof_fileno() -> Iterator[int]: + """A file descriptor permanently at EOF, like stdin whose terminal is gone.""" + read_fd, write_fd = os.pipe() + os.close(write_fd) + try: + yield read_fd + finally: + os.close(read_fd) + + +@pytest.fixture +def preserve_signal_handlers() -> Iterator[None]: + """Restore the handlers that `LinuxDriver.__init__` installs.""" + saved = { + number: signal.getsignal(number) for number in (signal.SIGTSTP, signal.SIGCONT) + } + try: + yield + finally: + for number, handler in saved.items(): + if handler is not None: + signal.signal(number, handler) + + +@pytest.mark.parametrize("driver_class", [LinuxDriver, LinuxInlineDriver]) +async def test_input_thread_stops_on_stdin_eof( + driver_class: type[Driver], + eof_fileno: int, + preserve_signal_handlers: None, +) -> None: + async with App().run_test() as pilot: + driver = driver_class(pilot.app) + driver.fileno = eof_fileno + + thread = threading.Thread(target=driver.run_input_thread, daemon=True) + thread.start() + thread.join(timeout=5.0) + spinning = thread.is_alive() + if spinning: + # Stop the thread before the fixture closes the descriptor under it. + driver.exit_event.set() + thread.join(timeout=5.0) + + assert not spinning, "input thread is busy-looping on a stdin that is at EOF"