#6692·textual

App.suspend() skips resume_application_mode() when the suspended body raises, deadlocking the app

Author: unalcubic-mCreated Aug 7, 2026Updated Sep 7, 2026

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

python
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():

python
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:

  1. 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.writeassert self._writer_thread is not None, "Driver must be in application mode" — still passes on a thread that has exited.
  2. 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:

python
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:

python
error: BaseException | None = None
with app.suspend():
    try:
        run_the_subprocess()
    except BaseException as exc:
        error = exc
if error is not None:
    raise error