Summary
App.suspend() resumes application mode in the statement after its yield, with nothing guarding it. If the suspended body raises, resume_application_mode() never runs. The driver stays stopped, but the app keeps running its timers and rendering — and because the stopped driver's WriterThread is dead while _writer_thread is still set, every subsequent repaint queues into a bounded Queue that nothing drains. After 30 writes the event loop thread blocks in queue.put permanently.
The result is a hard hang with no traceback: the terminal is left in canonical mode, the app never repaints again, and the process has to be killed.
Running a subprocess under suspend() and having it fail is an ordinary thing to do, so the failure path is easy to hit.
Environment
- Textual 8.2.8 (also current
main)
- Python 3.12.3
- Linux,
LinuxDriver
Reproduction
import pathlib
import time
from textual.app import App, ComposeResult
from textual.widgets import Static
BEAT = pathlib.Path(__file__).with_name("beat")
class Repro(App[None]):
def compose(self) -> ComposeResult:
yield Static("start", id="clock")
def on_mount(self) -> None:
self.set_interval(0.1, self.beat)
self.set_timer(1.0, self.trigger)
self.set_timer(10.0, self.exit) # never fires
def beat(self) -> None:
# Change visible content so every tick queues a real write.
self.query_one("#clock", Static).update(f"{time.time():.2f}")
BEAT.write_text(f"{time.time():.2f}")
def trigger(self) -> None:
try:
with self.suspend():
raise RuntimeError("the suspended command failed")
except RuntimeError:
pass # handled; the app is expected to carry on
Repro().run()
Run it in a real terminal. The RuntimeError is caught, so the app should keep going and exit after 10 seconds.
Observed: the heartbeat file stops updating within a few seconds of trigger, the 10-second exit timer never fires, and the process hangs until killed. Inspecting it:
- main thread blocked in
futex_do_wait
- thread count 1 — the
textual-output writer thread is gone
- the tty is left in canonical mode (
icanon), i.e. application mode was never resumed
py-spy dump on a hung process (captured from the real application where I first hit this):
Thread (idle): "MainThread"
wait (threading.py:355)
put (queue.py:140)
write (textual/drivers/_writer_thread.py:26)
write (textual/drivers/linux_driver.py:194)
_display (textual/app.py:3883)
_compositor_refresh (textual/screen.py:1224)
_refresh_layout (textual/screen.py:1390)
_on_timer_update (textual/screen.py:1241)
...
run (textual/app.py:2350)
Mechanism
app.py, in suspend():
with (
self._driver.no_automatic_restart(),
redirect_stdout(sys.__stdout__),
redirect_stderr(sys.__stderr__),
):
yield
# We're done with the dev's code so resume application mode.
self._driver.resume_application_mode()
self._resume_signal()
self.refresh(layout=True)
There is no try/finally, so an exception from the body propagates out of the generator and the three lines after the with are skipped.
Two things then combine to turn that into a deadlock rather than a visible error:
LinuxDriver.close() calls self._writer_thread.stop() (which pushes a sentinel and joins, ending the thread for good) but leaves self._writer_thread set. So the assert in LinuxDriver.write — assert self._writer_thread is not None, "Driver must be in application mode" — still passes on a thread that has exited.
WriterThread._queue is Queue(MAX_QUEUED_WRITES) with MAX_QUEUED_WRITES = 30, and WriterThread.write is a blocking put. With no consumer, write 31 blocks forever.
So the app degrades silently for 30 writes and then wedges the event loop.
Suggested fix
The main one — guard the resume:
try:
with (
self._driver.no_automatic_restart(),
redirect_stdout(sys.__stdout__),
redirect_stderr(sys.__stderr__),
):
yield
finally:
self._driver.resume_application_mode()
self._resume_signal()
self.refresh(layout=True)
Worth considering alongside it: having LinuxDriver.close() set self._writer_thread = None so that writing to a stopped driver trips the existing assert instead of deadlocking. That converts any future variant of this bug into an immediate, diagnosable error rather than a silent hang.
Workaround
For anyone else hitting this, capturing the exception so the with block always exits normally, then re-raising after, restores the app reliably:
error: BaseException | None = None
with app.suspend():
try:
run_the_subprocess()
except BaseException as exc:
error = exc
if error is not None:
raise error
Summary
App.suspend()resumes application mode in the statement after itsyield, with nothing guarding it. If the suspended body raises,resume_application_mode()never runs. The driver stays stopped, but the app keeps running its timers and rendering — and because the stopped driver'sWriterThreadis dead while_writer_threadis still set, every subsequent repaint queues into a boundedQueuethat nothing drains. After 30 writes the event loop thread blocks inqueue.putpermanently.The result is a hard hang with no traceback: the terminal is left in canonical mode, the app never repaints again, and the process has to be killed.
Running a subprocess under
suspend()and having it fail is an ordinary thing to do, so the failure path is easy to hit.Environment
main)LinuxDriverReproduction
Run it in a real terminal. The
RuntimeErroris caught, so the app should keep going and exit after 10 seconds.Observed: the heartbeat file stops updating within a few seconds of
trigger, the 10-second exit timer never fires, and the process hangs until killed. Inspecting it:futex_do_waittextual-outputwriter thread is goneicanon), i.e. application mode was never resumedpy-spy dumpon a hung process (captured from the real application where I first hit this):Mechanism
app.py, insuspend():There is no
try/finally, so an exception from the body propagates out of the generator and the three lines after thewithare skipped.Two things then combine to turn that into a deadlock rather than a visible error:
LinuxDriver.close()callsself._writer_thread.stop()(which pushes a sentinel and joins, ending the thread for good) but leavesself._writer_threadset. So the assert inLinuxDriver.write—assert self._writer_thread is not None, "Driver must be in application mode"— still passes on a thread that has exited.WriterThread._queueisQueue(MAX_QUEUED_WRITES)withMAX_QUEUED_WRITES = 30, andWriterThread.writeis a blockingput. With no consumer, write 31 blocks forever.So the app degrades silently for 30 writes and then wedges the event loop.
Suggested fix
The main one — guard the resume:
Worth considering alongside it: having
LinuxDriver.close()setself._writer_thread = Noneso that writing to a stopped driver trips the existing assert instead of deadlocking. That converts any future variant of this bug into an immediate, diagnosable error rather than a silent hang.Workaround
For anyone else hitting this, capturing the exception so the
withblock always exits normally, then re-raising after, restores the app reliably: