Skip to content

Commit 5fac73e

Browse files
committed
Stage 3 review: bound the display's teardown, not just the wait for it
The readiness timeout only bounded the waiting. Cleanup then joined the display thread with no timeout, so a render callback blocked inside it held the command thread anyway -- the hang moved rather than went away. The join is bounded now. A thread that will not finish is reported and kept: it is a daemon and still owns the application, so clearing the reference would let a second display start over it, and one terminal cannot have two input readers. Resuming refuses for the same reason, and a suspension that cannot restore its display says so once and carries on without a toolbar -- the command that suspended it is not this failure's to end. The earlier test suppressed the readiness signal while leaving the worker responsive, so the join it performed always returned promptly and the case could not appear. The new tests block a real render callback.
1 parent 471095d commit 5fac73e

2 files changed

Lines changed: 90 additions & 3 deletions

File tree

‎cmd2/command_toolbar.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
#: machine is not mistaken for a broken one, short enough that a command is never held forever.
3535
_STARTUP_TIMEOUT = 10.0
3636

37+
#: How long to wait for the display's thread to finish. Bounded for the same reason: a render
38+
#: callback blocked inside it would otherwise hold whoever is tearing the display down.
39+
_SHUTDOWN_TIMEOUT = 10.0
40+
3741
_F = TypeVar("_F", bound=Callable[..., Any])
3842
_R = TypeVar("_R")
3943

@@ -87,6 +91,14 @@ def pipe_target(stream: Any) -> Any:
8791
return stream
8892

8993

94+
class _DisplayStillRunningError(RuntimeError):
95+
"""Raised when a previous display's thread has not finished.
96+
97+
One terminal cannot have two input readers, so a display that would not stop is a display
98+
that cannot be started again.
99+
"""
100+
101+
90102
class _ContextStdoutProxy(StdoutProxy):
91103
"""Keep stdout's flush worker in the toolbar's isolated application session."""
92104

@@ -301,6 +313,10 @@ def _restore_stream(obj: Any, name: str, stream: ToolbarStream) -> None:
301313
setattr(obj, name, stream.original)
302314

303315
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")
304320
self._ready.clear()
305321
self._error = None
306322
stack = self._display_stack = contextlib.ExitStack()
@@ -433,8 +449,15 @@ def _pause(self) -> None:
433449
if self.app.is_running and self.app.loop is not None:
434450
self.app.loop.call_soon_threadsafe(self._exit)
435451
if self._thread is not None:
436-
self._thread.join()
437-
self._thread = None
452+
self._thread.join(timeout=_SHUTDOWN_TIMEOUT)
453+
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")
459+
else:
460+
self._thread = None
438461
# Return the borrowed application to the main prompt, including on
439462
# proxy failures. The upstream toolbar owned a separate application.
440463
if self._display_stack is not None:
@@ -578,4 +601,10 @@ def suspend(self) -> Iterator[None]:
578601
yield
579602
finally:
580603
with self.cmd.sigint_protection:
581-
self._resume()
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}")

‎tests/test_command_toolbar.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,3 +826,61 @@ def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch,
826826

827827
assert ran == [True]
828828
assert "did not start" in capsys.readouterr().err
829+
830+
831+
def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app, monkeypatch, capsys) -> None:
832+
"""A blocked render callback must not hold the command thread through teardown either.
833+
834+
The readiness wait being bounded is only half of it: the display thread is still inside
835+
the callback, so the join that follows has to be bounded too. This blocks the callback for
836+
real rather than suppressing the readiness signal, which is what the earlier test did and
837+
why it could not see this.
838+
"""
839+
app, _, _ = toolbar_app
840+
blocked = threading.Event()
841+
monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2)
842+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
843+
844+
def blocking_toolbar() -> str:
845+
blocked.wait(timeout=10)
846+
return "STATUS"
847+
848+
app.main_session.bottom_toolbar = blocking_toolbar
849+
ran = []
850+
try:
851+
started = time.monotonic()
852+
with app._command_toolbar_context():
853+
ran.append(True)
854+
elapsed = time.monotonic() - started
855+
856+
assert ran == [True]
857+
# Both bounded waits, and nothing unbounded between them.
858+
assert elapsed < 5
859+
assert "did not start" in capsys.readouterr().err
860+
finally:
861+
blocked.set()
862+
863+
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."""
866+
app, _, _ = toolbar_app
867+
blocked = threading.Event()
868+
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
869+
870+
try:
871+
with app._command_toolbar_context():
872+
display = app._command_toolbar
873+
assert display is not None
874+
first_thread = display._thread
875+
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()
879+
880+
with app.suspend_bottom_toolbar():
881+
pass
882+
883+
assert display._thread is first_thread
884+
assert "not restored" in capsys.readouterr().err
885+
finally:
886+
blocked.set()

0 commit comments

Comments
 (0)