Skip to content

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

Description

@unalcubic-m

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:

  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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions