Repository navigation
LinuxDriver input thread busy-loops at 100%+ CPU forever when stdin reaches EOF (terminal gone) #6737
Description
Have you checked closed issues? Yes (#3831 is related but is a crash on piped stdin, not a spin).
Have you checked against the most recent version of Textual? Yes, 8.2.8, and the code below is unchanged on main.
The bug
When the terminal an app is attached to disappears (SSH drop, killed tmux pane) and the process survives (SIGHUP not delivered or ignored, e.g. nohup), the app never exits and the input thread spins, consuming a full core indefinitely. On a shared host we have seen these run for days.
In drivers/linux_driver.py, run_input_thread / process_selector_events:
unicode_data = decode(read(fileno, 1024 * 4), final=final and last)
if not unicode_data:
# This can occur if the stdin is piped
breakAt EOF os.read returns b"" and break leaves only the inner for. The outer loop while not self.exit_event.is_set(): process_selector_events(selector.select(0.1)) then calls select again. EOF is permanently "readable", so select returns immediately and the loop never blocks.
Observed with strace on a real process (a Textual-based app detached from its terminal for weeks), repeating ~13,000 times per second:
read(0, "", 4096) = 0
select(1, [0], NULL, NULL, {tv_sec=0, tv_usec=100000}) = 1 (in [0], left {tv_sec=0, tv_usec=99999})
That thread is in R state; the other threads are idle in futex_wait.
Reproduction
app.py:
import signal
from textual.app import App
signal.signal(signal.SIGHUP, signal.SIG_IGN) # simulates SIGHUP not reaching the process
App().run()repro.py (runs the app in a pty, then closes the master end):
import os, pty, sys, time
pid, master = pty.fork()
if pid == 0:
os.execv(sys.executable, [sys.executable, "app.py"])
time.sleep(3)
def cpu(p):
f = open(f"/proc/{p}/stat").read().rsplit(")", 1)[1].split()
return (int(f[11]) + int(f[12])) / os.sysconf("SC_CLK_TCK")
os.close(master) # terminal goes away
time.sleep(1); a = cpu(pid); time.sleep(5); b = cpu(pid)
print(f"{(b - a) / 5 * 100:.0f}% CPU; alive={os.path.exists(f'/proc/{pid}')}")
os.kill(pid, 9)Result on Textual 8.2.8, Python 3.12, Linux 5.4: 127% CPU; alive=True. Before closing the master, the app uses ~0% CPU.
Expected
The app exits (or at minimum stops polling) when stdin reaches EOF on a terminal, rather than looping forever.
Suggested fix
Distinguish a piped stdin from a lost terminal. When read returns empty and os.isatty(fileno) was true at startup, treat it as a hangup: set exit_event / call app.exit(). For the piped case keep the current behaviour but stop polling that fd (e.g. selector.unregister(fileno) and wait on exit_event.wait(0.1)). Happy to send a PR if you'd like one.
Related
#5140 (graceful handling of termination signals) covers the signal side. This issue is the case where no signal is delivered and the EOF is the only indication.
Textual Diagnostics
Versions
| Name | Value |
|---|---|
| Textual | 8.2.8 |
| Rich | 15.0.0 |
Python
| Name | Value |
|---|---|
| Version | 3.12.11 |
| Implementation | CPython |
| Compiler | Clang 20.1.4 |
Operating System
| Name | Value |
|---|---|
| System | Linux |
| Release | 5.4.0-216-generic |
| Version | #236-Ubuntu SMP Fri Apr 11 19:53:21 UTC 2025 |
Terminal
| Name | Value |
|---|---|
| Terminal Application | vscode (1.141.0) |
| TERM | xterm-256color |
| COLORTERM | truecolor |
Activity
github-actions commentedon Oct 13, 2026
We found the following entries in the FAQ which you may find helpful:
Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.
This project is developed and maintained by Will McGugan. Consider sponsoring Will's work on this project (and others).
This is an automated reply, generated by FAQtory