Skip to content

Commit bc69f36

Browse files
committed
Restore command display state after pager exit failures
1 parent e1b04c3 commit bc69f36

2 files changed

Lines changed: 62 additions & 18 deletions

File tree

cmd2/command_toolbar.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,7 @@ def page(self, text: str, *, chop: bool) -> None:
733733
previous = (self.app.key_bindings, self.app.editing_mode, self.app.full_screen)
734734
entered = False
735735
restored = False
736+
close_error: BaseException | None = None
736737

737738
def restore() -> None:
738739
"""Give the application back to the display, whichever thread is doing it.
@@ -747,6 +748,9 @@ def restore() -> None:
747748
self._apply_display_layout()
748749
self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
749750
self.app.renderer.full_screen = self.app.full_screen
751+
# Even a failed erase or cursor request must return ownership of unfinished
752+
# lines to the command. This also runs when the display's loop has stopped.
753+
self._set_render_suppressed(True)
750754

751755
def enter() -> None:
752756
nonlocal entered
@@ -768,26 +772,34 @@ def leave() -> None:
768772
if not entered:
769773
return
770774
entered = False
771-
self.app.renderer.erase()
772-
restore()
775+
try:
776+
self.app.renderer.erase()
777+
finally:
778+
restore()
773779
self.app.renderer.request_absolute_cursor_position()
774-
# Back to ordinary command output, whose frames are suppressed again so the toolbar
775-
# stays put. Command finalization and the next prompt lift this in turn.
776-
self._set_render_suppressed(True)
777780
self.app.invalidate()
778781

779782
def close() -> None:
783+
nonlocal close_error
780784
# Switch bindings before the next key is processed, preserving
781785
# typeahead sent in the same terminal read as the pager's quit key.
782-
leave()
783-
pager.closed.set()
786+
try:
787+
leave()
788+
except BaseException as exc: # noqa: BLE001
789+
# Deliver callback failures to the waiting command, rather than leaving it
790+
# blocked while the event loop merely logs the exception.
791+
close_error = exc
792+
finally:
793+
pager.closed.set()
784794

785795
pager.on_close = close
786796

787797
try:
788798
self._call_in_ui(enter)
789799
while not pager.closed.wait(0.1):
790800
self._check_running()
801+
if close_error is not None:
802+
raise close_error
791803
finally:
792804
try:
793805
if self.thread_is_alive:

tests/test_reserved_terminal.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,21 @@ def stop_mid_page() -> None:
691691
# later command output keeps the toolbar rather than scrolling it away.
692692
assert display._proxy is not None
693693
assert all(stream.serializer is None for stream in display._streams)
694-
harness.app.poutput("after the pager")
695-
assert wait_for(lambda: any("after the pager" in row for row in terminal.screen.display))
696-
assert terminal.screen.display[-1].startswith("STATUS")
694+
output_rendered = threading.Event()
695+
696+
def after_output_render(_app) -> None:
697+
# Proxy output precedes its asynchronous redraw. Inspect a completed frame,
698+
# not the transient screen between the write and the toolbar repaint.
699+
rows = terminal.screen.display
700+
if any("after the pager" in row for row in rows) and rows[-1].startswith("STATUS"):
701+
output_rendered.set()
702+
703+
display.app.after_render += after_output_render
704+
try:
705+
harness.app.poutput("after the pager")
706+
assert output_rendered.wait(5)
707+
finally:
708+
display.app.after_render -= after_output_render
697709
harness.app.main_session.bottom_toolbar = "RECOVERED"
698710
display.app.invalidate()
699711
assert wait_for(lambda: terminal.screen.display[-1].startswith("RECOVERED"))
@@ -707,7 +719,11 @@ def test_the_pager_draws_its_content_over_the_reserved_toolbar(self, terminal_ha
707719
assert harness.app.reserved_toolbar.bridge._render_suppressed is True
708720
assert terminal.screen.margins is None
709721

710-
def test_pager_teardown_restores_the_display_even_if_leaving_raises(self, terminal_harness, monkeypatch) -> None:
722+
@pytest.mark.parametrize("quit_key", [False, True])
723+
@pytest.mark.parametrize("failure", ["erase", "request_absolute_cursor_position"])
724+
def test_pager_teardown_restores_the_display_even_if_leaving_raises(
725+
self, terminal_harness, monkeypatch, quit_key, failure
726+
) -> None:
711727
"""If the display cannot run the pager's exit on its own loop -- here the exit's erase
712728
raises -- page() must still put the display back itself. Left as the pager's, the
713729
full-screen flag and editing mode would carry into the next main prompt."""
@@ -726,27 +742,43 @@ def make_pager(*args: Any, **kwargs: Any) -> Any:
726742
bindings = display.app.key_bindings
727743
editing_mode = display.app.editing_mode
728744

729-
def erase_fails() -> None:
730-
raise ValueError("erase failed")
745+
original = getattr(display.app.renderer, failure)
746+
forced_close = threading.Event()
747+
748+
def exit_fails() -> None:
749+
monkeypatch.setattr(display.app.renderer, failure, original)
750+
raise ValueError("exit failed")
731751

732752
def drive() -> None:
733753
assert wait_for(lambda: terminal.screen.display[0].startswith("row 000"))
734-
# The exit's first act is an erase; make it raise, then end the pager without
735-
# its quit key so the exit runs from page()'s own teardown.
736-
monkeypatch.setattr(display.app.renderer, "erase", erase_fails)
737-
created[0].closed.set()
754+
monkeypatch.setattr(display.app.renderer, failure, exit_fails)
755+
if quit_key:
756+
harness.pipe.send_text("q")
757+
if not wait_for(created[0].closed.is_set, timeout=3):
758+
forced_close.set()
759+
created[0].closed.set()
760+
else:
761+
created[0].closed.set()
738762

739763
with ThreadPoolExecutor() as executor:
740764
future = executor.submit(drive)
741-
with pytest.raises(ValueError, match="erase failed"):
765+
with pytest.raises(ValueError, match="exit failed"):
742766
display.page(PAGER_BODY, chop=False)
743767
future.result(timeout=5)
744768

769+
assert not forced_close.is_set(), "the quit callback failed to release page()"
745770
assert display.app.full_screen is False
746771
assert display.app.renderer.full_screen is False
747772
assert display.app.layout is display._layout
748773
assert display.app.key_bindings is display._bindings or display.app.key_bindings is bindings
749774
assert display.app.editing_mode is editing_mode
775+
assert harness.app.reserved_toolbar.bridge._render_suppressed is True
776+
harness.app.stdout.write("PARTIAL")
777+
harness.app.stdout.flush()
778+
display._call_in_ui(display.app._redraw)
779+
harness.app.stdout.write("END\n")
780+
harness.app.stdout.flush()
781+
assert any("PARTIALEND" in row for row in terminal.screen.display)
750782

751783
def test_output_that_fits_is_printed_without_a_pager(self, terminal_harness) -> None:
752784
harness, terminal = terminal_harness

0 commit comments

Comments
 (0)