Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/textual/drivers/linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)

Expand Down
15 changes: 13 additions & 2 deletions src/textual/drivers/linux_inline_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand All @@ -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)

Expand Down
73 changes: 73 additions & 0 deletions tests/test_driver_input_eof.py
Original file line number Diff line number Diff line change
@@ -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"