Skip to content

Commit aaccfcb

Browse files
committed
Stage 3 review: a pause that timed out relinquished nothing, and now says so
Bounding the join stopped the hang but left the caller believing the display had stopped. It had not: the thread was still inside the application, still rendering and still reading input. Suspension went on to release the margins and run the guest, so two programs shared one terminal; the borrowed layout and key bindings went back to the main prompt underneath a thread still using them; and the display object was dropped at the end of the command, taking the only record of the problem with it. A timed-out pause now raises. Nothing that follows a successful pause happens: no handoff, no restoration, no typeahead transfer. The caller is the only one who knows what it was about to do with the terminal, so the failure goes to it rather than being reported and swallowed. The refusal is recorded on the application rather than on the display object, which the next command replaces. The thread outlives it, and one terminal cannot have two input readers. Startup keeps propagating the failure that brought it there rather than a secondary cleanup failure -- the session is disabled either way, and "did not start" is the useful half. A stop that fails during ordinary teardown still propagates, as it did before. Guarding _resume() against a live thread became unreachable once _pause() refuses instead of returning, so it is gone rather than left as a branch no test can reach.
1 parent 5fac73e commit aaccfcb

2 files changed

Lines changed: 94 additions & 30 deletions

File tree

‎cmd2/command_toolbar.py‎

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,10 @@ def pipe_target(stream: Any) -> Any:
9292

9393

9494
class _DisplayStillRunningError(RuntimeError):
95-
"""Raised when a previous display's thread has not finished.
95+
"""Raised when the display's thread did not finish within its timeout.
9696
97-
One terminal cannot have two input readers, so a display that would not stop is a display
98-
that cannot be started again.
97+
Nothing was relinquished: the thread is still inside the application, so whatever was
98+
about to be done with the terminal must not be.
9999
"""
100100

101101

@@ -304,7 +304,11 @@ def start(self) -> None:
304304
stack.callback(self._restore_stream, obj, name, wrapper)
305305
self._resume()
306306
except BaseException:
307-
self.stop()
307+
# A cleanup that cannot finish is reported by the state it leaves behind -- the
308+
# display is disabled for the session either way -- and the failure that brought
309+
# us here is the one worth propagating.
310+
with contextlib.suppress(Exception):
311+
self.stop()
308312
raise
309313

310314
@staticmethod
@@ -313,10 +317,6 @@ def _restore_stream(obj: Any, name: str, stream: ToolbarStream) -> None:
313317
setattr(obj, name, stream.original)
314318

315319
def _resume(self) -> None:
316-
if self._thread is not None and self._thread.is_alive():
317-
# A previous display never finished. One terminal cannot have two input readers,
318-
# and the old one still holds the application.
319-
raise _DisplayStillRunningError("the bottom toolbar's previous display is still running")
320320
self._ready.clear()
321321
self._error = None
322322
stack = self._display_stack = contextlib.ExitStack()
@@ -449,13 +449,11 @@ def _pause(self) -> None:
449449
if self.app.is_running and self.app.loop is not None:
450450
self.app.loop.call_soon_threadsafe(self._exit)
451451
if self._thread is not None:
452+
# Bounded, so a render callback blocked inside the display cannot hold the
453+
# thread that is tearing it down.
452454
self._thread.join(timeout=_SHUTDOWN_TIMEOUT)
453455
if self._thread.is_alive():
454-
# Bounded, so a render callback blocked inside the display cannot hold
455-
# the thread that is tearing it down. The reference is kept rather
456-
# than cleared: the thread is still running the application, and
457-
# starting a second one would put two input readers on one terminal.
458-
self.cmd.perror(f"The bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds")
456+
self._abandon_stuck_display()
459457
else:
460458
self._thread = None
461459
# Return the borrowed application to the main prompt, including on
@@ -471,6 +469,29 @@ def _pause(self) -> None:
471469
finally:
472470
self._pausing = False
473471

472+
def _abandon_stuck_display(self) -> None:
473+
"""Report that the display did not stop, and refuse to pretend it did.
474+
475+
A pause that timed out relinquished nothing. The thread is still inside the
476+
application: still rendering, still reading input. Everything that would normally
477+
follow a pause assumes the opposite -- the caller hands the terminal to a guest, the
478+
borrowed layout and key bindings go back to the main prompt, and the display object is
479+
dropped at the end of the command. Each of those would be acting on an application
480+
that is still running.
481+
482+
So nothing further happens here. The application's state is left as the running thread
483+
expects to find it, and the failure is raised rather than reported and swallowed,
484+
because only the caller knows what it was about to do with the terminal.
485+
486+
The refusal is recorded on the application rather than on this object, which the next
487+
command replaces. The thread outlives it, and one terminal cannot have two input
488+
readers.
489+
490+
:raises _DisplayStillRunningError: always
491+
"""
492+
self.cmd._command_toolbar_disabled = True
493+
raise _DisplayStillRunningError(f"the bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds")
494+
474495
def stop(self) -> None:
475496
"""Flush output, stop rendering, and restore the terminal and its streams."""
476497
try:
@@ -601,10 +622,4 @@ def suspend(self) -> Iterator[None]:
601622
yield
602623
finally:
603624
with self.cmd.sigint_protection:
604-
try:
605-
self._resume()
606-
except _DisplayStillRunningError as exc:
607-
# The display that would not stop is still holding the application, so
608-
# there is nothing to come back to. Report it once and carry on without a
609-
# toolbar: the command that suspended it is not this failure's to end.
610-
self.cmd.perror(f"Bottom toolbar not restored: {exc}")
625+
self._resume()

‎tests/test_command_toolbar.py‎

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Command toolbar lifecycle and terminal integration tests."""
22

3+
import contextlib
34
import sys
45
import threading
56
import time
@@ -861,26 +862,74 @@ def blocking_toolbar() -> str:
861862
blocked.set()
862863

863864

864-
def test_command_toolbar_does_not_start_a_second_display_over_a_stuck_one(toolbar_app, monkeypatch, capsys) -> None:
865-
"""One terminal, one input reader: a display that would not stop cannot be restarted."""
865+
def _block_the_display(app, blocked: threading.Event) -> None:
866+
"""Wedge the running display inside a render callback it cannot leave."""
867+
app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS"
868+
app._command_toolbar.app.invalidate()
869+
870+
871+
def test_command_toolbar_suspension_does_not_hand_over_a_terminal_it_still_owns(toolbar_app, monkeypatch) -> None:
872+
"""A pause that timed out did not stop anything, and must not pretend otherwise."""
866873
app, _, _ = toolbar_app
867874
blocked = threading.Event()
868875
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
876+
entered = []
869877

870878
try:
871-
with app._command_toolbar_context():
879+
# The command context's own teardown fails the same way, for the same reason: the
880+
# display never stopped. That is the established contract for a stop that fails.
881+
with contextlib.suppress(RuntimeError), app._command_toolbar_context():
872882
display = app._command_toolbar
873883
assert display is not None
874-
first_thread = display._thread
884+
_block_the_display(app, blocked)
875885

876-
# Block the display inside a render, so its thread cannot finish.
877-
app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS"
878-
display.app.invalidate()
886+
with pytest.raises(RuntimeError, match="did not stop"), app.suspend_bottom_toolbar():
887+
entered.append(True)
879888

880-
with app.suspend_bottom_toolbar():
889+
# The guest never ran: the display still owns the application and the terminal.
890+
assert entered == []
891+
assert display.app.is_running is True
892+
finally:
893+
blocked.set()
894+
895+
896+
def test_command_toolbar_that_would_not_stop_is_not_used_again(toolbar_app, monkeypatch) -> None:
897+
"""The surviving thread outlives this display object, so the refusal has to as well."""
898+
app, _, _ = toolbar_app
899+
blocked = threading.Event()
900+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
901+
902+
try:
903+
with contextlib.suppress(RuntimeError), app._command_toolbar_context():
904+
_block_the_display(app, blocked)
905+
with contextlib.suppress(RuntimeError), app.suspend_bottom_toolbar():
906+
pass
907+
908+
assert app._command_toolbar_disabled is True
909+
910+
# A later command must not start a second display over the one still running.
911+
with app._command_toolbar_context():
912+
assert app._command_toolbar is None
913+
finally:
914+
blocked.set()
915+
916+
917+
def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app, monkeypatch) -> None:
918+
"""Its layout and bindings are still in use; restoring them would pull them out from under it."""
919+
app, _, _ = toolbar_app
920+
blocked = threading.Event()
921+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
922+
923+
try:
924+
with contextlib.suppress(RuntimeError), app._command_toolbar_context():
925+
display = app._command_toolbar
926+
assert display is not None
927+
layout = display.app.layout
928+
_block_the_display(app, blocked)
929+
930+
with contextlib.suppress(RuntimeError), app.suspend_bottom_toolbar():
881931
pass
882932

883-
assert display._thread is first_thread
884-
assert "not restored" in capsys.readouterr().err
933+
assert display.app.layout is layout
885934
finally:
886935
blocked.set()

0 commit comments

Comments
 (0)