Skip to content

Fix input thread busy-looping at 100% CPU when stdin reaches EOF - #6690

Open
m-k-l-s wants to merge 3 commits into
Textualize:mainfrom
m-k-l-s:eof-busy-loop-fix
Open

Fix input thread busy-looping at 100% CPU when stdin reaches EOF#6690
m-k-l-s wants to merge 3 commits into
Textualize:mainfrom
m-k-l-s:eof-busy-loop-fix

Conversation

@m-k-l-s

@m-k-l-s m-k-l-s commented Aug 7, 2026

Copy link
Copy Markdown

Motivation

We are using Textual for an in-house TUI for monitoring long-running jobs (usually from a remote machine via SSH). We've noticed weird hanging TUI processes on the remote machine when the SSH connections dropped/died without receiving SIGHUP.

The TUI process (that started and then monitored a job) would then live on with a dead stdin and it would use 100% CPU indefinitely.

Cause

Turns out that if a textual process's stdin reaches EOF (can also be easily reproduced locally, see below), the input thread loops forever and cannot be cleanly exited. Only tested on macOS and Linux (i.e., using LinuxDrivers).

Fix

Check for EOF before decoding, and stop reading instead of looping forever.

Changes

After the fix, a textual app whose stdin is at EOF no longer uses 100% of a core: the input thread stops and the app keeps running, as it does on the other drivers when their input ends.

MRE / test / CI

(Links are from my own fork for now, cause CI doesn't run here without manual approval.)

  1. New regression test / MRE fails on purpose, showing the problem: https://github.com/m-k-l-s/textual/actions/runs/31022228475
  2. After adding a fix, tests pass: https://github.com/m-k-l-s/textual/actions/runs/31022266962

Exiting on EOF instead?

In our case, we would actually prefer to cleanly exit on stdin EOF, but I suppose that is not what the default behaviour should be. Either way, it would look like this:

--- a/src/textual/drivers/linux_driver.py
+++ b/src/textual/drivers/linux_driver.py
-from textual.messages import InBandWindowResize
+from textual.messages import ExitApp, InBandWindowResize

 # ...at the end of run_input_thread, after the try/finally:
+        if eof:
+            self.send_message(ExitApp())

And similarly LinuxInlineDriver. send_message is thread-safe, so the app then shuts down through its normal path, which also restores the terminal.

Reproducing manually

To test (tested with textual==8.2.8):

# how to reproduce 8.2.8 with this PR
1 python -m textual < /dev/null, then watch that process in top 100% of one core, steady state ~0%; app keeps running, and Ctrl+C still exits cleanly and restores the terminal
2 python -m textual — an ordinary run, as a control idle, ~0% idle, ~0% (unchanged)
3 python -m textual over SSH or in a VS Code terminal, then close the terminal so the connection drops without SIGHUP burns 100% of a core indefinitely ~0%; the process still lingers, since the app is left to decide when to exit
4 pytest tests/test_driver_input_eof.py 2 failed 2 passed

AI policy

Discovering the root cause was a lot of trial and error, both human and LLMs. Fix itself is implemented via Claude Code, Opus 5. PR description (except for the table above^) is written by hand.

m-k-l-s added 2 commits August 5, 2026 17:48
A selector reports a file descriptor at EOF as readable, so select() returns
immediately, forever. The input thread never notices, so it busy-loops and
pegs a CPU core for the life of the app.

Covers LinuxDriver and LinuxInlineDriver, which carry independent copies of
the same loop. Expected to fail at this commit; the fix follows.
os.read() returning b"" means stdin is at EOF, but it 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 permanently readable.

Detect EOF on the raw read and stop selecting. The app is left running, as it
is on the other drivers when their input ends.

Applies to both LinuxDriver and LinuxInlineDriver.
@m-k-l-s
m-k-l-s marked this pull request as ready for review August 7, 2026 11:23
Copilot AI lite review requested due to automatic review settings August 7, 2026 11:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a runaway CPU usage issue in the POSIX (Linux/macOS) input thread when stdin reaches EOF (e.g., stdin redirected from /dev/null or an SSH session drops and stdin closes). The fix ensures the selector loop terminates instead of repeatedly treating EOF as a permanently-readable file descriptor.

Changes:

  • Detect os.read(...) == b"" (EOF) in LinuxDriver and LinuxInlineDriver input loops and stop selecting to prevent busy-looping.
  • Add a regression test that asserts the input thread terminates promptly when its input FD is permanently at EOF.
  • Document the fix in the changelog under “Unreleased”.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/textual/drivers/linux_driver.py Stops the POSIX input thread when stdin hits EOF to prevent a 100% CPU busy loop.
src/textual/drivers/linux_inline_driver.py Applies the same EOF handling to the inline POSIX driver input thread.
tests/test_driver_input_eof.py Adds a regression test ensuring the input thread exits when reading from an EOF FD.
CHANGELOG.md Notes the bugfix under “Unreleased”.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread CHANGELOG.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants