Skip to content

Commit aab9e23

Browse files
committed
Stage 3 review: refuse the terminal while a display that would not stop still holds it
Disabling future command displays only stopped another one from starting. The thread that would not stop was still inside the application -- rendering, and reading the same input -- and everything else went on as though the terminal were free: startup reported its timeout, the command ran, and the next suspension released the margins and handed a guest a terminal cmd2 did not own. The application now keeps a reference to the display that did not let go, and refuses to pause, prompt or hand over until it does. That covers the paths that matter through the two context managers every caller already goes through: the main prompt, nested prompts, external commands, pagers and finalization. The refusal heals itself. A render callback that finally returns, a subprocess that finally exits, and the thread ends; the next check finds it gone and stops refusing, rather than leaving the session broken for a condition that has passed.
1 parent aaccfcb commit aab9e23

4 files changed

Lines changed: 140 additions & 1 deletion

File tree

‎cmd2/cmd2.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,9 @@ def __init__(
561561
# so that a typo fails where it was written.
562562
self._bottom_toolbar_mode = validate_toolbar_mode(bottom_toolbar_mode)
563563
self._reserved_toolbar: ReservedToolbar | None = None
564+
# A command display whose thread did not stop when it was asked to. It still owns the
565+
# terminal, so nothing may be handed the terminal until it lets go.
566+
self._display_holding_terminal: command_toolbar.CommandToolbar | None = None
564567

565568
# Create the main PromptSession
566569
self.main_session = self._create_main_session(
@@ -2135,6 +2138,28 @@ def get_bottom_toolbar(self) -> AnyFormattedText:
21352138
"""
21362139
return None
21372140

2141+
def _require_terminal_ownership(self) -> None:
2142+
"""Refuse to use the terminal while a display that would not stop still holds it.
2143+
2144+
A display that timed out on shutdown is disabled for the rest of the session, but that
2145+
only stops another one from starting. Its thread is still inside the application:
2146+
rendering, and reading the same input. Handing that terminal to a guest, or prompting
2147+
on it, would put two readers on one device and interleave their output.
2148+
2149+
The check heals itself. The thread may finish late -- a render callback that finally
2150+
returned, a subprocess that finally exited -- and once it has, the terminal is ours
2151+
again and this stops refusing.
2152+
2153+
:raises RuntimeError: while the surviving display still holds the terminal
2154+
"""
2155+
display = self._display_holding_terminal
2156+
if display is None:
2157+
return
2158+
if not display.thread_is_alive:
2159+
self._display_holding_terminal = None
2160+
return
2161+
raise RuntimeError("the bottom toolbar's display has not released the terminal")
2162+
21382163
@contextlib.contextmanager
21392164
def suspend_bottom_toolbar(self) -> Iterator[None]:
21402165
"""Temporarily hide the command toolbar and give exclusive access to the terminal.
@@ -2149,6 +2174,7 @@ def suspend_bottom_toolbar(self) -> Iterator[None]:
21492174
"""
21502175
with self._quiesce_bottom_toolbar():
21512176
reserved = self._reserved_toolbar
2177+
21522178
if reserved is None:
21532179
yield
21542180
else:
@@ -2166,6 +2192,7 @@ def _quiesce_bottom_toolbar(self) -> Iterator[None]:
21662192
something else is another, and the ordinary end of a command needs only the first --
21672193
the toolbar has to still be there when the next prompt appears.
21682194
"""
2195+
self._require_terminal_ownership()
21692196
if self._command_toolbar is None:
21702197
yield
21712198
else:

‎cmd2/command_toolbar.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,11 +485,13 @@ def _abandon_stuck_display(self) -> None:
485485
486486
The refusal is recorded on the application rather than on this object, which the next
487487
command replaces. The thread outlives it, and one terminal cannot have two input
488-
readers.
488+
readers -- so the application keeps a reference to the display that did not let go,
489+
and refuses to hand the terminal anywhere until it does.
489490
490491
:raises _DisplayStillRunningError: always
491492
"""
492493
self.cmd._command_toolbar_disabled = True
494+
self.cmd._display_holding_terminal = self
493495
raise _DisplayStillRunningError(f"the bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds")
494496

495497
def stop(self) -> None:
@@ -503,6 +505,11 @@ def stop(self) -> None:
503505
self._stack.close()
504506
self._stack = None
505507

508+
@property
509+
def thread_is_alive(self) -> bool:
510+
"""Whether the display's thread is still running."""
511+
return self._thread is not None and self._thread.is_alive()
512+
506513
@property
507514
def is_active(self) -> bool:
508515
"""Whether the display currently owns the terminal."""

‎tests/test_command_toolbar.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,3 +933,80 @@ def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app,
933933
assert display.app.layout is layout
934934
finally:
935935
blocked.set()
936+
937+
938+
def test_a_surviving_display_blocks_later_handoffs(toolbar_app, monkeypatch) -> None:
939+
"""Disabling future displays is not enough: the old one still owns the terminal."""
940+
app, _, _ = toolbar_app
941+
blocked = threading.Event()
942+
monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2)
943+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
944+
entered = []
945+
946+
try:
947+
app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS"
948+
with app._command_toolbar_context():
949+
pass
950+
951+
# The command display is gone as an object, but its thread is not.
952+
assert app._command_toolbar is None
953+
with pytest.raises(RuntimeError, match="terminal"), app.suspend_bottom_toolbar():
954+
entered.append(True)
955+
assert entered == []
956+
finally:
957+
blocked.set()
958+
959+
960+
def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None:
961+
"""Two readers on one terminal is not a state to keep prompting in."""
962+
app, _, _ = toolbar_app
963+
blocked = threading.Event()
964+
monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2)
965+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
966+
967+
try:
968+
app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS"
969+
with app._command_toolbar_context():
970+
pass
971+
972+
with pytest.raises(RuntimeError, match="terminal"):
973+
app._read_raw_input("> ", app.main_session)
974+
finally:
975+
blocked.set()
976+
977+
978+
def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypatch) -> None:
979+
"""The thread may yet finish, and the session should not stay broken if it does."""
980+
app, _, _ = toolbar_app
981+
blocked = threading.Event()
982+
monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2)
983+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
984+
985+
app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS"
986+
with app._command_toolbar_context():
987+
pass
988+
surviving = app._display_holding_terminal
989+
assert surviving is not None
990+
991+
blocked.set()
992+
surviving._thread.join(timeout=5)
993+
994+
with app.suspend_bottom_toolbar():
995+
pass
996+
assert app._display_holding_terminal is None
997+
998+
999+
def test_a_surviving_display_stops_another_from_starting(toolbar_app, monkeypatch) -> None:
1000+
app, _, _ = toolbar_app
1001+
blocked = threading.Event()
1002+
monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2)
1003+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
1004+
1005+
try:
1006+
app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS"
1007+
with app._command_toolbar_context():
1008+
pass
1009+
with app._command_toolbar_context():
1010+
assert app._command_toolbar is None
1011+
finally:
1012+
blocked.set()

‎tests/test_reserved_lifecycle.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,3 +570,31 @@ def test_another_session_still_gets_the_terminal_to_itself(self) -> None:
570570
assert toolbar.display.is_reserved is True
571571
finally:
572572
harness.close()
573+
574+
575+
class TestSurvivingDisplay:
576+
def test_the_rows_are_not_released_for_a_terminal_we_do_not_own(self) -> None:
577+
"""A guest cannot be given rows back while another reader still holds the terminal."""
578+
harness = Harness(mode="reserved")
579+
try:
580+
with harness.app._reserved_toolbar_context():
581+
toolbar = harness.app.reserved_toolbar
582+
assert toolbar is not None
583+
584+
# Stand in for a display whose thread never stopped.
585+
harness.app._display_holding_terminal = StuckDisplay()
586+
587+
harness.clear()
588+
with pytest.raises(RuntimeError, match="terminal"), harness.app.suspend_bottom_toolbar():
589+
pass
590+
591+
assert toolbar.display.is_reserved is True
592+
assert "\x1b[r" not in harness.written()
593+
finally:
594+
harness.close()
595+
596+
597+
class StuckDisplay:
598+
"""A command display whose thread will not finish."""
599+
600+
thread_is_alive = True

0 commit comments

Comments
 (0)