Skip to content

Commit c9a5abe

Browse files
committed
Stage 3 review: finish the deferred teardown before letting go of the refusal
A pause that timed out left the application as the running thread expected to find it: its layout, its key bindings, its erase-on-done setting. That was the right thing to do while the thread was alive. Once the thread ended, that state belonged to nobody, and simply dropping the refusal handed the next prompt an application still dressed as the command display. The teardown the pause could not do is finished at the moment the refusal lifts, and the refusal lifts only if it could be. Both halves of the pause -- returning the borrowed application, and handing over the keys typed meanwhile -- are now one method, so the deferred path cannot drift from the ordinary one. The regression asserts the layout, bindings and erase setting are back, not just that the refusal is gone. Checking only the refusal is what let this through.
1 parent aab9e23 commit c9a5abe

4 files changed

Lines changed: 57 additions & 14 deletions

File tree

‎cmd2/cmd2.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2155,7 +2155,10 @@ def _require_terminal_ownership(self) -> None:
21552155
display = self._display_holding_terminal
21562156
if display is None:
21572157
return
2158-
if not display.thread_is_alive:
2158+
if display.complete_abandoned_shutdown():
2159+
# Its thread has ended, so the teardown its timed-out pause could not do has been
2160+
# finished here. Clearing the reference without that would hand the next prompt an
2161+
# application still dressed as the command display.
21592162
self._display_holding_terminal = None
21602163
return
21612164
raise RuntimeError("the bottom toolbar's display has not released the terminal")

‎cmd2/command_toolbar.py‎

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -454,21 +454,43 @@ def _pause(self) -> None:
454454
self._thread.join(timeout=_SHUTDOWN_TIMEOUT)
455455
if self._thread.is_alive():
456456
self._abandon_stuck_display()
457-
else:
458-
self._thread = None
459-
# Return the borrowed application to the main prompt, including on
460-
# proxy failures. The upstream toolbar owned a separate application.
461-
if self._display_stack is not None:
462-
self._display_stack.close()
463-
self._display_stack = None
464-
# Application.run() saves its unprocessed queue before the thread
465-
# exits. Those keys arrived after the ones handled by save_key().
466-
pending_keys = get_typeahead(self.app.input)
467-
store_typeahead(self.app.input, self._keys + pending_keys)
468-
self._keys.clear()
457+
self._finish_pause()
469458
finally:
470459
self._pausing = False
471460

461+
def _finish_pause(self) -> None:
462+
"""Give the borrowed application back and hand over the keys typed meanwhile.
463+
464+
Only safe once the display's thread has ended: until then it is still using the layout
465+
and key bindings this puts back.
466+
"""
467+
self._thread = None
468+
# Return the borrowed application to the main prompt, including on proxy failures.
469+
# The upstream toolbar owned a separate application.
470+
if self._display_stack is not None:
471+
self._display_stack.close()
472+
self._display_stack = None
473+
# Application.run() saves its unprocessed queue before the thread exits. Those keys
474+
# arrived after the ones handled by save_key().
475+
pending_keys = get_typeahead(self.app.input)
476+
store_typeahead(self.app.input, self._keys + pending_keys)
477+
self._keys.clear()
478+
479+
def complete_abandoned_shutdown(self) -> bool:
480+
"""Finish the teardown a timed-out pause could not do, if the thread has since ended.
481+
482+
A pause that gave up left the application as the running thread expected to find it:
483+
its layout, its key bindings, its erase-on-done setting. Once the thread is gone that
484+
state belongs to nobody, and the next prompt would otherwise render as the command
485+
display.
486+
487+
:return: whether the display has now been fully torn down
488+
"""
489+
if self.thread_is_alive:
490+
return False
491+
self._finish_pause()
492+
return True
493+
472494
def _abandon_stuck_display(self) -> None:
473495
"""Report that the display did not stop, and refuse to pretend it did.
474496

‎tests/test_command_toolbar.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,24 +976,38 @@ def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None
976976

977977

978978
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."""
979+
"""The thread may yet finish, and the session should not stay broken if it does.
980+
981+
Lifting the refusal is not the whole of it. The pause that timed out never restored the
982+
application it had borrowed, so the prompt that comes next would render with the command
983+
display's layout and key bindings unless that teardown is finished first.
984+
"""
980985
app, _, _ = toolbar_app
981986
blocked = threading.Event()
982987
monkeypatch.setattr(command_toolbar, "_STARTUP_TIMEOUT", 0.2)
983988
monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.2)
984989

990+
prompt_layout = app.main_session.app.layout
991+
prompt_bindings = app.main_session.app.key_bindings
992+
prompt_erase = app.main_session.app.erase_when_done
993+
985994
app.main_session.bottom_toolbar = lambda: blocked.wait(timeout=10) or "STATUS"
986995
with app._command_toolbar_context():
987996
pass
988997
surviving = app._display_holding_terminal
989998
assert surviving is not None
999+
assert app.main_session.app.layout is not prompt_layout
9901000

9911001
blocked.set()
9921002
surviving._thread.join(timeout=5)
9931003

9941004
with app.suspend_bottom_toolbar():
9951005
pass
1006+
9961007
assert app._display_holding_terminal is None
1008+
assert app.main_session.app.layout is prompt_layout
1009+
assert app.main_session.app.key_bindings is prompt_bindings
1010+
assert app.main_session.app.erase_when_done == prompt_erase
9971011

9981012

9991013
def test_a_surviving_display_stops_another_from_starting(toolbar_app, monkeypatch) -> None:

‎tests/test_reserved_lifecycle.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,3 +598,7 @@ class StuckDisplay:
598598
"""A command display whose thread will not finish."""
599599

600600
thread_is_alive = True
601+
602+
def complete_abandoned_shutdown(self) -> bool:
603+
"""Report that the teardown cannot be finished while the thread runs."""
604+
return False

0 commit comments

Comments
 (0)