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–2070 — pre_await callback in dismiss
textual/await_complete.py:61–65 — AwaitComplete.__await__ calls pre_await
textual/app.py:3117–3121 — pop_screen creates AwaitComplete with call_next
Attempted Workarounds
set_pre_await_callback(None) on the AwaitComplete — does not fix, asyncio.gather still blocks.
- Replacing
dismiss to not return AwaitComplete — the call_next callback still blocks.
- 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).
Description
ModalScreen.dismiss()causesrun_test()to hang indefinitely when the dismiss is dispatched viacall_after_refresh. This affects all tests that push aModalScreenand attempt to dismiss it using thecall_after_refreshpattern. The test only terminates when a test-timeout (e.g.,pytest-timeout) fires, typically after ~7 seconds.Environment
Minimal Reproducer
Save the following as
test_repro.pyand run withpytest test_repro.py --timeout=10:Expected Behavior
The test passes in under 1 second.
Actual Behavior
The test hangs indefinitely on
await pilot.pause()(or anypilotinteraction) afterdismiss()has been scheduled viacall_after_refresh. Thepytest-timeoutplugin eventually kills the test after ~7 seconds.Root Cause Analysis
ModalScreen.dismiss()callsself.app.pop_screen()which returns anAwaitComplete(do_pop()).call_next(self).The
AwaitCompletewraps an asyncdo_pop()→_replace_screen()call. Underrun_test(),pilot.pause()processescall_nextcallbacks, triggering theAwaitCompletewhich blocks because of thepre_awaitcallback attextual/screen.pylines 2062–2070. Even nullifyingpre_awaitviaset_pre_await_callback(None)does not resolve the issue — the underlyingasyncio.gatherstill 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–2070—pre_awaitcallback indismisstextual/await_complete.py:61–65—AwaitComplete.__await__callspre_awaittextual/app.py:3117–3121—pop_screencreatesAwaitCompletewithcall_nextAttempted Workarounds
set_pre_await_callback(None)on theAwaitComplete— does not fix,asyncio.gatherstill blocks.dismissto not returnAwaitComplete— thecall_nextcallback still blocks.dismissto fire callback + pop stack directly — the only working workaround, confirming the hang is purely in theAwaitComplete/pop_screen/_replace_screenpump interaction.Workaround
Monkey-patch
ModalScreen.dismissto fire the callback and pop the stack manually without involvingAwaitComplete. This is safe for test use only:Impact
Any test suite that uses
ModalScreenwithcall_after_refreshto dismiss is affected. This is a common pattern for closing modal dialogs after asynchronous operations (e.g., form submission, network calls).