Repository navigation
Conversation
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.
There was a problem hiding this comment.
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) inLinuxDriverandLinuxInlineDriverinput 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.
Textual's driver for Linux and macOS reads the end of its input as nothing typed and goes straight back to waiting, so an app whose terminal has gone away - an ssh session dropped without a SIGHUP, a pty closed by the harness that opened it - held a core forever, and so did one started with its input on the null device. Upstream: Textualize/textual#6690. Stand a subclass in for that driver that reads the end of input, or a read the kernel refuses, as the end of the session and asks the app to leave. A driver named in the environment, Windows and the browser are left as they were.
|
Validated this locally against current
#6737 reports the same select/EOF spin on 8.2.8 (with an strace of ~13k reads/sec), so merging this would close that issue too. The stop-polling choice (input thread returns, app keeps running) looks right to me: exiting the whole app on stdin EOF would break apps that are deliberately piped and only render. Happy to help with a rebase if useful. |
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.)
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:
And similarly
LinuxInlineDriver.send_messageis 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):python -m textual < /dev/null, then watch that process intoppython -m textual— an ordinary run, as a controlpython -m textualover SSH or in a VS Code terminal, then close the terminal so the connection drops without SIGHUPpytest tests/test_driver_input_eof.pyAI 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.