Skip to content

ModalScreen.dismiss() via call_after_refresh hangs run_test() under Textual 8.x #6600

Description

@bitflicker64

Description

ModalScreen.dismiss() causes run_test() to hang indefinitely when the dismiss is dispatched via call_after_refresh. This affects all tests that push a ModalScreen and attempt to dismiss it using the call_after_refresh pattern. The test only terminates when a test-timeout (e.g., pytest-timeout) fires, typically after ~7 seconds.

Environment

  • Textual version: 8.2.7 (observed on 8.x)
  • Python version: 3.x
  • OS: All (reproducible cross-platform)

Minimal Reproducer

Save the following as test_repro.py and run with pytest test_repro.py --timeout=10:

import pytest
from textual.app import App
from textual.screen import ModalScreen
from textual.widgets import Button

class MyScreen(ModalScreen[dict]):
    def compose(self):
        yield Button("ok", id="btn-ok")

    def on_button_pressed(self, event):
        self.call_after_refresh(self.dismiss, {"key": "value"})

class MyApp(App):
    pass

@pytest.mark.asyncio
async def test_repro():
    app = MyApp()
    async with app.run_test() as pilot:
        screen = MyScreen()
        await app.push_screen(screen)
        await pilot.pause()
        await pilot.click("#btn-ok")
        await pilot.pause()
        assert True

Expected Behavior

The test passes in under 1 second.

Actual Behavior

The test hangs indefinitely on await pilot.pause() (or any pilot interaction) after dismiss() has been scheduled via call_after_refresh. The pytest-timeout plugin eventually kills the test after ~7 seconds.

Root Cause Analysis

ModalScreen.dismiss() calls self.app.pop_screen() which returns an AwaitComplete(do_pop()).call_next(self).

The AwaitComplete wraps an async do_pop()_replace_screen() call. Under run_test(), pilot.pause() processes call_next callbacks, triggering the AwaitComplete which blocks because of the pre_await callback at textual/screen.py lines 2062–2070. Even nullifying pre_await via set_pre_await_callback(None) does not resolve the issue — the underlying asyncio.gather still blocks on _replace_screen, which requires the screen pump to process messages while the pump is already processing.

Key Code References

  • textual/screen.py:2062–2070pre_await callback in dismiss
  • textual/await_complete.py:61–65AwaitComplete.__await__ calls pre_await
  • textual/app.py:3117–3121pop_screen creates AwaitComplete with call_next

Attempted Workarounds

  1. set_pre_await_callback(None) on the AwaitComplete — does not fix, asyncio.gather still blocks.
  2. Replacing dismiss to not return AwaitComplete — the call_next callback still blocks.
  3. Monkey-patching dismiss to fire callback + pop stack directly — the only working workaround, confirming the hang is purely in the AwaitComplete/pop_screen/_replace_screen pump interaction.

Workaround

Monkey-patch ModalScreen.dismiss to fire the callback and pop the stack manually without involving AwaitComplete. This is safe for test use only:

original_dismiss = ModalScreen.dismiss
async def _patched_dismiss(self, result=None):
    callback = self._dismiss_callback
    self._dismiss_callback = None
    if callback:
        callback(result)
    self.app.pop_screen_no_await()
ModalScreen.dismiss = _patched_dismiss

Impact

Any test suite that uses ModalScreen with call_after_refresh to dismiss is affected. This is a common pattern for closing modal dialogs after asynchronous operations (e.g., form submission, network calls).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions