Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 <!-- TODO: link issue/PR when filed -->
Comment thread
m-k-l-s marked this conversation as resolved.
Outdated

## [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"