From 3a40898950793ea006dd9654ce58b3aed5e97845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Zelinka?= Date: Wed, 5 Aug 2026 15:37:55 +0200 Subject: [PATCH 1/3] test: add failing regression test for input thread busy-loop on stdin EOF A POSIX selector reports a file descriptor at EOF as being readable, so once stdin hits permanent EOF the input thread's select() returns immediately forever: the app pegs a CPU core and, having no input left, can never be quit. This happens whenever stdin is at EOF while the app keeps running -- a redirect from /dev/null or an exhausted file, or a terminal that goes away without delivering SIGHUP (a closed VS Code remote terminal, a dropped SSH session). The test covers both LinuxDriver and LinuxInlineDriver, which carry independent copies of the same loop. It is expected to FAIL at this commit; the fix follows. --- tests/test_driver_input_eof.py | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/test_driver_input_eof.py diff --git a/tests/test_driver_input_eof.py b/tests/test_driver_input_eof.py new file mode 100644 index 0000000000..1a65ff5972 --- /dev/null +++ b/tests/test_driver_input_eof.py @@ -0,0 +1,98 @@ +"""Regression tests for the input thread when stdin reaches end-of-file. + +A POSIX selector reports a file descriptor that is at EOF as being *readable*, +so `selector.select()` returns immediately, forever. Unless the input thread +notices the EOF and stops selecting, it busy-loops and pegs a CPU core for the +whole life of the app -- and because there is no input left, the user has no way +of quitting it either. + +This happens whenever stdin is at EOF while the app keeps running: a redirect +from /dev/null or an exhausted file, or a terminal that goes away without +delivering SIGHUP (a closed VS Code remote terminal, a dropped SSH session). +""" + +import os +import signal +import sys +import threading +from typing import Iterator + +import pytest + +from textual.app import App +from textual.driver import Driver +from textual.messages import ExitApp + +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 that is permanently at EOF. + + A pipe whose write end is already closed behaves exactly like stdin does + once the terminal on the other side has gone away. + """ + 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: + """The input thread must terminate on EOF rather than spin on the selector.""" + sent: list[object] = [] + + class RecordingDriver(driver_class): # type: ignore[misc,valid-type] + def send_message(self, message: object) -> None: + sent.append(message) + + async with App().run_test() as pilot: + driver = RecordingDriver(pilot.app) + driver.fileno = eof_fileno + + thread = threading.Thread(target=driver.run_input_thread) + thread.start() + thread.join(timeout=5.0) + spinning = thread.is_alive() + if spinning: + # Shut the thread down before the fixture closes the descriptor + # underneath it, so a failure here doesn't also leak a hot thread. + driver.exit_event.set() + thread.join(timeout=5.0) + + assert not spinning, ( + "input thread is still running: it is busy-looping on a stdin that " + "is at EOF, which burns 100% of a CPU core" + ) + + # With no input left there is no way for the user to quit, so the driver has + # to ask the app to shut down; that also restores the terminal on the way out. + assert any( + isinstance(message, ExitApp) for message in sent + ), f"driver did not request app exit after stdin EOF; sent: {sent!r}" From ffb28f8a3f87ac94a4a8ffd9035e8f06e94dc84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Zelinka?= Date: Wed, 5 Aug 2026 15:38:51 +0200 Subject: [PATCH 2/3] fix: stop input thread busy-looping when stdin reaches EOF os.read() returning b"" means stdin is at permanent EOF, but the read was conflated with a decode that yields "" for an incomplete UTF-8 sequence, and the resulting break only left the inner loop. The outer loop kept calling select() on a descriptor that EOF reports as forever readable, so the input thread spun at 100% CPU for the life of the app. Detect the EOF on the raw read instead, leave the outer loop, and ask the app to exit: with no input left the user has no way of quitting it, and going through the normal shutdown path also restores the terminal (without this the app holds the terminal in application mode -- alt screen on, cursor hidden -- until it is killed). The exit is requested via Driver.send_message, which posts through the event loop threadsafely; WebDriver already does the same when its input stream ends. Piped input is still fully processed before the exit. Applies to both LinuxDriver and LinuxInlineDriver. --- CHANGELOG.md | 6 ++++++ src/textual/drivers/linux_driver.py | 25 +++++++++++++++++++--- src/textual/drivers/linux_inline_driver.py | 24 +++++++++++++++++++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67652d343d..69db6bcc11 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, and the app now exits (restoring the terminal) instead of hanging + ## [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..48a0034e0e 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -21,7 +21,7 @@ from textual.drivers._writer_thread import WriterThread from textual.geometry import Size from textual.message import Message -from textual.messages import InBandWindowResize +from textual.messages import ExitApp, InBandWindowResize if TYPE_CHECKING: from textual.app import App @@ -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,20 @@ 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: + # End of file: stdin will never produce input again. + # A selector reports a file descriptor at EOF as being + # permanently readable, so we must stop selecting on it + # or this thread would busy-loop, pegging a core. + 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; wait for the remaining bytes. break for event in feed(unicode_data): self.process_message(event) @@ -456,6 +467,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) @@ -467,6 +480,12 @@ def process_selector_events( except (EOFError, ParseError): pass + if eof: + # Input has gone away for good, so the user has no way of quitting + # the app. Ask it to shut down, which also restores the terminal. + # WebDriver does the same when its input stream ends. + self.send_message(ExitApp()) + def process_message(self, message: Message) -> None: # intercept in-band window resize if isinstance(message, InBandWindowResize): diff --git a/src/textual/drivers/linux_inline_driver.py b/src/textual/drivers/linux_inline_driver.py index 14aa61fba0..a843cbb90f 100644 --- a/src/textual/drivers/linux_inline_driver.py +++ b/src/textual/drivers/linux_inline_driver.py @@ -19,6 +19,7 @@ from textual._xterm_parser import XTermParser from textual.driver import Driver from textual.geometry import Size +from textual.messages import ExitApp if TYPE_CHECKING: from textual.app import App @@ -134,6 +135,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 +148,20 @@ 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: + # End of file: stdin will never produce input again. + # A selector reports a file descriptor at EOF as being + # permanently readable, so we must stop selecting on it + # or this thread would busy-loop, pegging a core. + 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; wait for the remaining bytes. break for event in feed(unicode_data): if isinstance(event, events.CursorPosition): @@ -165,6 +177,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) @@ -176,6 +190,12 @@ def process_selector_events( except ParseError: pass + if eof: + # Input has gone away for good, so the user has no way of quitting + # the app. Ask it to shut down, which also restores the terminal. + # WebDriver does the same when its input stream ends. + self.send_message(ExitApp()) + def start_application_mode(self) -> None: loop = asyncio.get_running_loop() From 59633c4ef584bce9388068ebc6d79d26db13ae55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikul=C3=A1=C5=A1=20Zelinka?= Date: Wed, 5 Aug 2026 17:24:31 +0200 Subject: [PATCH 3/3] don't exit the app when stdin reaches EOF Stopping the busy-loop is the fix; exiting is a policy no other driver applies. WebDriver, the input readers and the headless driver all leave the app running when their input ends, and the existing "this can occur if the stdin is piped" comment shows carrying on was the intent. Leave the decision to exit to the app. --- CHANGELOG.md | 2 +- src/textual/drivers/linux_driver.py | 16 ++------ src/textual/drivers/linux_inline_driver.py | 15 ++------ tests/test_driver_input_eof.py | 45 +++++----------------- 4 files changed, 18 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69db6bcc11..6297fd5a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fixed the input thread busy-looping at 100% CPU when stdin reaches EOF, and the app now exits (restoring the terminal) instead of hanging +- Fixed the input thread busy-looping at 100% CPU when stdin reaches EOF ## [8.2.8] - 2026-06-30 diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 48a0034e0e..b6b48b88cf 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -21,7 +21,7 @@ from textual.drivers._writer_thread import WriterThread from textual.geometry import Size from textual.message import Message -from textual.messages import ExitApp, InBandWindowResize +from textual.messages import InBandWindowResize if TYPE_CHECKING: from textual.app import App @@ -449,15 +449,13 @@ def process_selector_events( if mask & EVENT_READ: raw_data = read(fileno, 1024 * 4) if not raw_data: - # End of file: stdin will never produce input again. - # A selector reports a file descriptor at EOF as being - # permanently readable, so we must stop selecting on it - # or this thread would busy-loop, pegging a core. + # 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: - # Incomplete UTF-8 sequence; wait for the remaining bytes. + # Incomplete UTF-8 sequence. break for event in feed(unicode_data): self.process_message(event) @@ -480,12 +478,6 @@ def process_selector_events( except (EOFError, ParseError): pass - if eof: - # Input has gone away for good, so the user has no way of quitting - # the app. Ask it to shut down, which also restores the terminal. - # WebDriver does the same when its input stream ends. - self.send_message(ExitApp()) - def process_message(self, message: Message) -> None: # intercept in-band window resize if isinstance(message, InBandWindowResize): diff --git a/src/textual/drivers/linux_inline_driver.py b/src/textual/drivers/linux_inline_driver.py index a843cbb90f..0c1209ce80 100644 --- a/src/textual/drivers/linux_inline_driver.py +++ b/src/textual/drivers/linux_inline_driver.py @@ -19,7 +19,6 @@ from textual._xterm_parser import XTermParser from textual.driver import Driver from textual.geometry import Size -from textual.messages import ExitApp if TYPE_CHECKING: from textual.app import App @@ -153,15 +152,13 @@ def process_selector_events( if mask & EVENT_READ: raw_data = read(fileno, 1024 * 4) if not raw_data: - # End of file: stdin will never produce input again. - # A selector reports a file descriptor at EOF as being - # permanently readable, so we must stop selecting on it - # or this thread would busy-loop, pegging a core. + # 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: - # Incomplete UTF-8 sequence; wait for the remaining bytes. + # Incomplete UTF-8 sequence. break for event in feed(unicode_data): if isinstance(event, events.CursorPosition): @@ -190,12 +187,6 @@ def process_selector_events( except ParseError: pass - if eof: - # Input has gone away for good, so the user has no way of quitting - # the app. Ask it to shut down, which also restores the terminal. - # WebDriver does the same when its input stream ends. - self.send_message(ExitApp()) - def start_application_mode(self) -> None: loop = asyncio.get_running_loop() diff --git a/tests/test_driver_input_eof.py b/tests/test_driver_input_eof.py index 1a65ff5972..1915272070 100644 --- a/tests/test_driver_input_eof.py +++ b/tests/test_driver_input_eof.py @@ -1,14 +1,11 @@ -"""Regression tests for the input thread when stdin reaches end-of-file. +"""Regression test for the input thread when stdin reaches end-of-file. -A POSIX selector reports a file descriptor that is at EOF as being *readable*, -so `selector.select()` returns immediately, forever. Unless the input thread -notices the EOF and stops selecting, it busy-loops and pegs a CPU core for the -whole life of the app -- and because there is no input left, the user has no way -of quitting it either. +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. -This happens whenever stdin is at EOF while the app keeps running: a redirect -from /dev/null or an exhausted file, or a terminal that goes away without -delivering SIGHUP (a closed VS Code remote terminal, a dropped SSH session). +Asserting the thread returns is a deterministic stand-in for measuring CPU: a +thread that has returned cannot busy-loop. """ import os @@ -21,7 +18,6 @@ from textual.app import App from textual.driver import Driver -from textual.messages import ExitApp if sys.platform == "win32": pytest.skip("LinuxDriver/LinuxInlineDriver are POSIX only", allow_module_level=True) @@ -32,11 +28,7 @@ @pytest.fixture def eof_fileno() -> Iterator[int]: - """A file descriptor that is permanently at EOF. - - A pipe whose write end is already closed behaves exactly like stdin does - once the terminal on the other side has gone away. - """ + """A file descriptor permanently at EOF, like stdin whose terminal is gone.""" read_fd, write_fd = os.pipe() os.close(write_fd) try: @@ -65,15 +57,8 @@ async def test_input_thread_stops_on_stdin_eof( eof_fileno: int, preserve_signal_handlers: None, ) -> None: - """The input thread must terminate on EOF rather than spin on the selector.""" - sent: list[object] = [] - - class RecordingDriver(driver_class): # type: ignore[misc,valid-type] - def send_message(self, message: object) -> None: - sent.append(message) - async with App().run_test() as pilot: - driver = RecordingDriver(pilot.app) + driver = driver_class(pilot.app) driver.fileno = eof_fileno thread = threading.Thread(target=driver.run_input_thread) @@ -81,18 +66,8 @@ def send_message(self, message: object) -> None: thread.join(timeout=5.0) spinning = thread.is_alive() if spinning: - # Shut the thread down before the fixture closes the descriptor - # underneath it, so a failure here doesn't also leak a hot thread. + # 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 still running: it is busy-looping on a stdin that " - "is at EOF, which burns 100% of a CPU core" - ) - - # With no input left there is no way for the user to quit, so the driver has - # to ask the app to shut down; that also restores the terminal on the way out. - assert any( - isinstance(message, ExitApp) for message in sent - ), f"driver did not request app exit after stdin EOF; sent: {sent!r}" + assert not spinning, "input thread is busy-looping on a stdin that is at EOF"