Skip to content

Commit 690c38e

Browse files
committed
Harden the pager's exit and the fallback under an open pager
Seven review findings on the pager-layout fix, in one change. The pager's teardown could skip its restore: if the display died between the liveness check and the scheduled exit callback, or the exit raised before restoring, page() left the full-screen flag and editing mode as the pager's, and the next main prompt rendered in the alternate screen. The restore now runs through one nested restore(), on the display's loop when the exit runs and on the command thread when it does not, so it always runs exactly once. Falling back while the pager was open reset the renderer, which quits the alternate screen and flashes the command output through, and swapped the layout under the pager. While the pager is on screen the fallback now only switches routing and redraws, so the native toolbar appears on the pager's bottom row; the layout and renderer reset wait for the pager's exit, which applies the display's current layout. One accessor now applies the display's layout to the application, so the resume, the fallback and the pager's exit all pick up whichever layout the display has, and the pager no longer saves a copy to restore. The pager's teardown and two other sites use the class's own thread_is_alive. Tests: the mid-pager fallback test now asserts the toolbar is visible and the pager still up while it is open, and that the alternate screen is never quit on the wire; a new test fails the pager's exit and checks the display is restored; pager tests share one driving helper, which closes a pager that ignores its quit key so a regression fails instead of hanging. Validation: 2626 passed, 6 skipped with coverage, twice; the mutations that drop the unconditional restore and that reset under the pager each fail their test; harness acceptance and dynamic gates PASS at 12, 24 and 40 rows, 23/23 observer controls. make check, make test and make docs-test passed.
1 parent 33647c9 commit 690c38e

4 files changed

Lines changed: 152 additions & 57 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
- Falling back from reserved rendering during a command restores the native toolbar layout and
1111
stdout proxy, so a recovered toolbar remains visible during the command, including when the
1212
fallback happens while the command has handed the terminal to another program or while the
13-
built-in pager is open.
13+
built-in pager is open. A fallback during paging no longer flashes the command output through
14+
the pager, and the pager's exit restores the display even if that exit fails.
1415
- Resuming the reserved command display after a terminal handoff preserves the cursor column of
1516
unfinished guest output, so later command output continues the same line.
1617
- A reserved toolbar started in a terminal below the minimum height now activates when the

cmd2/command_toolbar.py

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,9 @@ def _resume(self) -> None:
341341
# a line the last command left in progress. Its final frame is a suppressed no-op
342342
# instead, which leaves that output alone.
343343
erase_when_done = self._reserved_bridge() is None
344-
for name, value in (("layout", self._layout), ("key_bindings", self._bindings), ("erase_when_done", erase_when_done)):
344+
stack.callback(setattr, self.app, "layout", self.app.layout)
345+
self._apply_display_layout()
346+
for name, value in (("key_bindings", self._bindings), ("erase_when_done", erase_when_done)):
345347
stack.callback(setattr, self.app, name, getattr(self.app, name))
346348
setattr(self.app, name, value)
347349
self.app.after_render += self._display_started
@@ -417,23 +419,34 @@ def _reservation_stopped(self) -> None:
417419
bridge behind it, and the native toolbar would never appear for the rest of the
418420
command. Routing and the redraw of a display that *is* running need its loop.
419421
"""
420-
previous_layout = self._layout
421422
self._layout = self._legacy_layout()
422423
if self.app.loop is not None and self.app.is_running:
423-
self.app.loop.call_soon_threadsafe(self._restore_legacy_display, previous_layout)
424+
self.app.loop.call_soon_threadsafe(self._restore_legacy_display)
424425

425-
def _restore_legacy_display(self, previous_layout: Layout) -> None:
426-
"""Switch a running display over to legacy routing and layout, on its own loop.
426+
def _apply_display_layout(self) -> None:
427+
"""Put the display's own layout on the application.
427428
428-
:param previous_layout: the reserved layout the display was started with, replaced on
429-
the application only if it is still the one in use
429+
The one place that assignment is made from ``_layout``. The display's layout can
430+
change while a command runs -- the reservation being abandoned switches it to the
431+
legacy one -- and everything that hands the application back to the display, whether
432+
a resume or the pager's exit, comes through here and so picks up whichever it is now.
430433
"""
434+
self.app.layout = self._layout
435+
436+
def _restore_legacy_display(self) -> None:
437+
"""Switch a running display over to legacy routing and layout, on its own loop."""
431438
if self._pausing or not self.app.is_running or self.app.is_done:
432439
return
433440
self._install_legacy_proxy()
434-
if self.app.layout is previous_layout:
435-
self.app.layout = self._layout
436441
self.app.erase_when_done = True
442+
if self.app.full_screen:
443+
# The pager is on screen. Its exit applies the display's layout and resets the
444+
# renderer; doing either here would quit the alternate screen under it and flash
445+
# the command output through. Routing is switched now, and a redraw shows the
446+
# native toolbar on the pager's bottom row; the rest waits for the pager's exit.
447+
self.app.invalidate()
448+
return
449+
self._apply_display_layout()
437450
self.app.renderer.reset()
438451
self.app.renderer.request_absolute_cursor_position()
439452
self.app.invalidate()
@@ -550,7 +563,7 @@ def _pause(self) -> None:
550563
# Bounded, so a render callback blocked inside the display cannot hold the
551564
# thread that is tearing it down.
552565
self._thread.join(timeout=_SHUTDOWN_TIMEOUT)
553-
if self._thread.is_alive():
566+
if self.thread_is_alive:
554567
self._abandon_stuck_display()
555568
self._finish_pause()
556569
finally:
@@ -669,7 +682,7 @@ def call() -> None:
669682
return value
670683

671684
def _check_running(self) -> None:
672-
if self._thread is None or not self._thread.is_alive():
685+
if not self.thread_is_alive:
673686
if self._error is not None:
674687
raise self._error
675688
raise EOFError
@@ -691,12 +704,22 @@ def page(self, text: str, *, chop: bool) -> None:
691704
filter=Condition(lambda: suspend_to_background_supported() and to_filter(self.cmd.main_session.enable_suspend)()),
692705
)(self._suspend_binding)
693706
layout = Layout(HSplit([pager.container, self.toolbar]), focused_element=pager.text)
694-
# The layout is deliberately not saved here. The display's layout can change while the
695-
# pager is open -- a reservation abandoned mid-page switches it to the legacy one -- and
696-
# restoring the layout saved on entry would put the obsolete reserved layout back,
697-
# leaving no toolbar. Pager exit reads the display's current layout instead.
698707
previous = (self.app.key_bindings, self.app.editing_mode, self.app.full_screen)
699708
entered = False
709+
restored = False
710+
711+
def restore() -> None:
712+
"""Give the application back to the display, whichever thread is doing it.
713+
714+
Plain attribute assignments, so this is safe from the display's loop and from the
715+
command thread alike. The layout is not restored from a saved copy: the display's
716+
layout can change while the pager is open, and the accessor applies the current one.
717+
"""
718+
nonlocal restored
719+
restored = True
720+
self._apply_display_layout()
721+
self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
722+
self.app.renderer.full_screen = self.app.full_screen
700723

701724
def enter() -> None:
702725
nonlocal entered
@@ -718,9 +741,7 @@ def leave() -> None:
718741
return
719742
entered = False
720743
self.app.renderer.erase()
721-
self.app.layout = self._layout
722-
self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
723-
self.app.renderer.full_screen = self.app.full_screen
744+
restore()
724745
self.app.renderer.request_absolute_cursor_position()
725746
# Back to ordinary command output, whose frames are suppressed again so the toolbar
726747
# stays put. Command finalization and the next prompt lift this in turn.
@@ -740,13 +761,16 @@ def close() -> None:
740761
while not pager.closed.wait(0.1):
741762
self._check_running()
742763
finally:
743-
if self._thread is not None and self._thread.is_alive():
744-
self._call_in_ui(leave)
745-
else:
746-
# The application's shutdown already reset the renderer.
747-
self.app.layout = self._layout
748-
self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
749-
self.app.renderer.full_screen = self.app.full_screen
764+
try:
765+
if self.thread_is_alive:
766+
self._call_in_ui(leave)
767+
finally:
768+
# Restored here if the exit never ran: the display had already shut down (its
769+
# shutdown reset the renderer itself), or the display died between the check
770+
# and the callback, or the exit raised before restoring. Left as the pager's,
771+
# the full-screen flag and editing mode would carry into the next prompt.
772+
if not restored:
773+
restore()
750774

751775
@contextlib.contextmanager
752776
def suspend(self) -> Iterator[None]:

tests/test_command_toolbar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,6 @@ def test_restoring_the_legacy_display_on_a_stopped_display_changes_nothing(toolb
11731173
display = app._command_toolbar
11741174
assert display is not None
11751175
layout = app.main_session.app.layout
1176-
display._restore_legacy_display(display._layout)
1176+
display._restore_legacy_display()
11771177
assert display._proxy is None
11781178
assert app.main_session.app.layout is layout

tests/test_reserved_terminal.py

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
import time
88
from concurrent.futures import ThreadPoolExecutor
99
from types import SimpleNamespace
10+
from typing import Any
11+
from unittest import mock
1012

1113
import pyte
1214
import pytest
1315
from prompt_toolkit.application import run_in_terminal
1416
from prompt_toolkit.data_structures import Size
1517

18+
from cmd2 import command_toolbar
1619
from cmd2.reserved_toolbar import ReservedToolbar
1720
from cmd2.utils import StdSim
1821

@@ -560,6 +563,50 @@ def test_a_carriage_return_progress_line_ends_on_its_final_value(self, terminal_
560563
assert terminal.screen.display[-1].startswith("STATUS")
561564

562565

566+
PAGER_BODY = "\n".join(f"row {index:03d}" for index in range(200))
567+
568+
569+
def run_pager(harness, terminal, while_open=None) -> bool:
570+
"""Page a body taller than the screen on the command display, then quit the pager.
571+
572+
``while_open`` runs on the driving thread once the pager has painted its first screen.
573+
The quit key is sent either way, so a pager that never draws fails the caller's
574+
assertion instead of hanging the blocking ``page()`` call forever.
575+
576+
:return: whether the pager drew its first screen
577+
"""
578+
display = harness.app._command_toolbar
579+
shown = threading.Event()
580+
created: list[Any] = []
581+
real_pager = command_toolbar.Pager
582+
583+
def make_pager(*args: Any, **kwargs: Any) -> Any:
584+
pager = real_pager(*args, **kwargs)
585+
created.append(pager)
586+
return pager
587+
588+
def drive() -> None:
589+
try:
590+
if wait_for(lambda: terminal.screen.display[0].startswith("row 000")):
591+
shown.set()
592+
if while_open is not None:
593+
while_open()
594+
finally:
595+
harness.pipe.send_text("q")
596+
# A pager that no longer answers its quit key -- its layout swapped out from
597+
# under it, say -- would leave page() blocked forever. Close it by hand so the
598+
# test fails on the assertion instead of hanging.
599+
if created and not wait_for(created[0].closed.is_set, timeout=3):
600+
created[0].closed.set()
601+
raise AssertionError("the pager did not close on its quit key")
602+
603+
with mock.patch.object(command_toolbar, "Pager", make_pager), ThreadPoolExecutor() as executor:
604+
future = executor.submit(drive)
605+
display.page(PAGER_BODY, chop=False)
606+
future.result(timeout=5)
607+
return shown.is_set()
608+
609+
563610
class TestPager:
564611
"""The built-in pager renders a full screen of its own, so its frames must not be
565612
suppressed the way an ordinary command's empty frames are."""
@@ -621,21 +668,22 @@ def test_abandoning_reservation_while_the_pager_is_open_keeps_the_fallback_layou
621668
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
622669
reserved = harness.app.reserved_toolbar
623670
display = harness.app._command_toolbar
624-
body = "\n".join(f"row {index:03d}" for index in range(200))
625-
626-
def drive() -> None:
627-
try:
628-
if wait_for(lambda: terminal.screen.display[0].startswith("row 000")):
629-
display.app.loop.call_soon_threadsafe(reserved.stop)
630-
wait_for(lambda: reserved.bridge is None)
631-
finally:
632-
harness.pipe.send_text("q")
633-
634-
with ThreadPoolExecutor() as executor:
635-
future = executor.submit(drive)
636-
display.page(body, chop=False)
637-
future.result(timeout=5)
638-
671+
seen: dict[str, bool] = {}
672+
673+
def stop_mid_page() -> None:
674+
emitted_before = len(terminal.getvalue())
675+
display.app.loop.call_soon_threadsafe(reserved.stop)
676+
assert wait_for(lambda: reserved.bridge is None)
677+
# The fallback must not drop out of the pager: its screen stays up, and the
678+
# native toolbar is visible on its bottom row while it is open. The emulator
679+
# does not model the alternate screen, so the flash a renderer reset would
680+
# cause is checked on the wire: the sequence that quits it is never sent.
681+
seen["toolbar"] = wait_for(lambda: terminal.screen.display[-1].startswith("STATUS"))
682+
seen["pager"] = terminal.screen.display[0].startswith("row 000")
683+
seen["stayed_in_pager"] = "\x1b[?1049l" not in terminal.getvalue()[emitted_before:]
684+
685+
assert run_pager(harness, terminal, while_open=stop_mid_page)
686+
assert seen == {"toolbar": True, "pager": True, "stayed_in_pager": True}
639687
assert reserved.bridge is None
640688
assert len(display.app.layout.container.children) == 3
641689
harness.app.main_session.bottom_toolbar = "RECOVERED"
@@ -645,30 +693,52 @@ def drive() -> None:
645693

646694
def test_the_pager_draws_its_content_over_the_reserved_toolbar(self, terminal_harness) -> None:
647695
harness, terminal = terminal_harness
696+
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
697+
assert run_pager(harness, terminal), "the pager never drew its content"
698+
# The toolbar is suppressed again for ordinary output once the pager has closed.
699+
assert harness.app.reserved_toolbar.bridge._render_suppressed is True
700+
assert terminal.screen.margins is None
701+
702+
def test_pager_teardown_restores_the_display_even_if_leaving_raises(self, terminal_harness, monkeypatch) -> None:
703+
"""If the display cannot run the pager's exit on its own loop -- here the exit's erase
704+
raises -- page() must still put the display back itself. Left as the pager's, the
705+
full-screen flag and editing mode would carry into the next main prompt."""
706+
harness, terminal = terminal_harness
707+
created: list[Any] = []
708+
real_pager = command_toolbar.Pager
709+
710+
def make_pager(*args: Any, **kwargs: Any) -> Any:
711+
pager = real_pager(*args, **kwargs)
712+
created.append(pager)
713+
return pager
714+
715+
monkeypatch.setattr(command_toolbar, "Pager", make_pager)
648716
with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context():
649717
display = harness.app._command_toolbar
650-
body = "\n".join(f"row {index:03d}" for index in range(200))
651-
shown = threading.Event()
718+
bindings = display.app.key_bindings
719+
editing_mode = display.app.editing_mode
720+
721+
def erase_fails() -> None:
722+
raise ValueError("erase failed")
652723

653724
def drive() -> None:
654-
# Wait until the pager has painted its first screen, then quit it. Quit either
655-
# way, so a pager that never draws fails the assertion instead of hanging the
656-
# blocking page() call forever.
657-
try:
658-
if wait_for(lambda: terminal.screen.display[0].startswith("row 000")):
659-
shown.set()
660-
finally:
661-
harness.pipe.send_text("q")
725+
assert wait_for(lambda: terminal.screen.display[0].startswith("row 000"))
726+
# The exit's first act is an erase; make it raise, then end the pager without
727+
# its quit key so the exit runs from page()'s own teardown.
728+
monkeypatch.setattr(display.app.renderer, "erase", erase_fails)
729+
created[0].closed.set()
662730

663731
with ThreadPoolExecutor() as executor:
664732
future = executor.submit(drive)
665-
display.page(body, chop=False)
733+
with pytest.raises(ValueError, match="erase failed"):
734+
display.page(PAGER_BODY, chop=False)
666735
future.result(timeout=5)
667736

668-
assert shown.is_set(), "the pager never drew its content"
669-
# The toolbar is suppressed again for ordinary output once the pager has closed.
670-
assert harness.app.reserved_toolbar.bridge._render_suppressed is True
671-
assert terminal.screen.margins is None
737+
assert display.app.full_screen is False
738+
assert display.app.renderer.full_screen is False
739+
assert display.app.layout is display._layout
740+
assert display.app.key_bindings is display._bindings or display.app.key_bindings is bindings
741+
assert display.app.editing_mode is editing_mode
672742

673743
def test_output_that_fits_is_printed_without_a_pager(self, terminal_harness) -> None:
674744
harness, terminal = terminal_harness

0 commit comments

Comments
 (0)