1919
2020from cmd2 import Cmd , ToolbarMode , command_toolbar
2121
22- from .conftest import RecordingOutput , Terminal
22+ from .conftest import ContendedLock , RecordingOutput , Terminal
2323
2424
2525def test_command_toolbar_refresh_and_output (toolbar_app , monkeypatch ) -> None :
@@ -89,7 +89,7 @@ def command(statement, **kwargs):
8989 assert app .stdout is output
9090
9191
92- def test_command_toolbar_pipe_output (toolbar_app ) -> None :
92+ def test_command_toolbar_pipe_output (toolbar_app , running_pipe_process ) -> None :
9393 app , _ , output = toolbar_app
9494 with app ._command_toolbar_context ():
9595 app .onecmd_plus_hooks (f'help | "{ sys .executable } " -c "import sys; print(sys.stdin.read().upper())"' )
@@ -110,7 +110,7 @@ def __getattr__(self, name):
110110
111111
112112@pytest .mark .parametrize ("builtin_pager" , [False , True ])
113- def test_command_toolbar_pipe_process_inherits_terminal (toolbar_app , tmp_path , builtin_pager ) -> None :
113+ def test_command_toolbar_pipe_process_inherits_terminal (toolbar_app , tmp_path , builtin_pager , running_pipe_process ) -> None :
114114 app , _ , _ = toolbar_app
115115 app .use_builtin_pager = builtin_pager
116116 destination = tmp_path / "terminal.txt"
@@ -289,31 +289,35 @@ async def expire_cpr() -> None:
289289 assert "last words\n " in output .getvalue ()
290290
291291
292- def test_command_toolbar_suspension_waits_for_in_flight_writes (toolbar_app ) -> None :
292+ def test_command_toolbar_suspension_waits_for_in_flight_writes (toolbar_app , monkeypatch ) -> None :
293293 app , _ , output = toolbar_app
294294 writing = threading .Event ()
295+ observed = ContendedLock ()
296+ original_init = command_toolbar .CommandToolbar .__init__
297+
298+ def init (display , * args , ** kwargs ):
299+ original_init (display , * args , ** kwargs )
300+ display ._lock = observed
301+
302+ monkeypatch .setattr (command_toolbar .CommandToolbar , "__init__" , init )
295303
296304 with app ._command_toolbar_context ():
297305 proxy = app ._command_toolbar ._proxy
298306 proxy_write = proxy .write
299307
300308 def slow_write (data : str ) -> int :
301- # Widen the window in which suspending could close this proxy. A closed
302- # proxy accepts writes and discards them, so the output would vanish.
309+ # Do not finish the write until suspension actually tries to take its lock.
303310 writing .set ()
304- time . sleep ( 0.1 )
311+ assert observed . contended . wait ( 5 ), "pause did not wait for the writer"
305312 return proxy_write (data )
306313
307314 proxy .write = slow_write
308- thread = threading .Thread (target = lambda : app .poutput ("in flight" ))
309- thread .start ()
310- assert writing .wait (2 )
311-
312- # A command reaches this at every finalization boundary while its own
313- # threads are still printing.
314- with app .suspend_bottom_toolbar ():
315- pass
316- thread .join ()
315+ with ThreadPoolExecutor (max_workers = 1 ) as pool :
316+ pending = pool .submit (app .poutput , "in flight" )
317+ assert writing .wait (5 )
318+ with app .suspend_bottom_toolbar ():
319+ pass
320+ pending .result (timeout = 5 )
317321
318322 assert "in flight\n " in output .getvalue ()
319323
@@ -419,7 +423,7 @@ def already_exiting():
419423 assert app .main_session .app .layout is app .main_session .layout
420424
421425
422- def test_command_toolbar_ui_call_propagates_failures (toolbar_app ) -> None :
426+ def test_command_toolbar_ui_call_propagates_failures (toolbar_app , monkeypatch ) -> None :
423427 app , _ , _ = toolbar_app
424428
425429 def fail (exception : BaseException ) -> None :
@@ -439,7 +443,37 @@ def fail(exception: BaseException) -> None:
439443 toolbar ._call_in_ui (lambda : fail (TimeoutError ("slow ui call" )))
440444
441445 # A callback that outlives the poll interval keeps waiting instead of giving up.
442- assert toolbar ._call_in_ui (lambda : time .sleep (0.2 ) or "finished" ) == "finished"
446+ entered = threading .Event ()
447+ release = threading .Event ()
448+
449+ class PendingFuture (Future ):
450+ polled = False
451+
452+ def result (self , timeout = None ):
453+ if not self .polled :
454+ self .polled = True
455+ assert timeout is not None
456+ assert entered .wait (5 )
457+ raise FutureTimeoutError
458+ return super ().result (timeout = 5 )
459+
460+ def finish ():
461+ entered .set ()
462+ assert release .wait (5 )
463+ return "finished"
464+
465+ check_running = toolbar ._check_running
466+
467+ def checked ():
468+ check_running ()
469+ release .set ()
470+
471+ monkeypatch .setattr (command_toolbar , "Future" , PendingFuture )
472+ monkeypatch .setattr (toolbar , "_check_running" , checked )
473+ try :
474+ assert toolbar ._call_in_ui (finish ) == "finished"
475+ finally :
476+ release .set ()
443477
444478
445479def test_command_toolbar_ui_call_returns_a_result_that_lands_during_the_poll (toolbar_app , monkeypatch ) -> None :
@@ -487,14 +521,24 @@ def test_command_toolbar_ui_call_after_display_stopped(toolbar_app) -> None:
487521 toolbar ._call_in_ui (lambda : None )
488522
489523
490- def test_command_toolbar_ui_call_reports_display_failure (toolbar_app , capsys ) -> None :
524+ def test_command_toolbar_ui_call_reports_display_failure (toolbar_app , capsys , monkeypatch ) -> None :
491525 app , _ , _ = toolbar_app
492526 with app ._command_toolbar_context ():
493527 toolbar = app ._command_toolbar
494528 loop = toolbar .app .loop
495529 schedule = loop .call_soon_threadsafe
496530 failed = threading .Event ()
497531
532+ class FailedDisplayFuture (Future ):
533+ def result (self , timeout = None ):
534+ assert timeout is not None
535+ toolbar ._thread .join (5 )
536+ assert not toolbar ._thread .is_alive ()
537+ assert not self .done ()
538+ raise FutureTimeoutError
539+
540+ monkeypatch .setattr (command_toolbar , "Future" , FailedDisplayFuture )
541+
498542 def die (* args , ** kwargs ):
499543 # The display dies instead of running the queued callback, so the future
500544 # the command is waiting on never resolves. Only drop that one request,
@@ -788,7 +832,7 @@ def external(*args, **kwargs):
788832 assert app ._command_toolbar .is_active
789833
790834
791- def test_builtin_pager_eof_restores_prompt (toolbar_app ) -> None :
835+ def test_builtin_pager_eof_restores_prompt (toolbar_app , monkeypatch ) -> None :
792836 app , pipe , output = toolbar_app
793837 layout = app .main_session .app .layout
794838
@@ -797,6 +841,23 @@ def close_input(ui):
797841 pipe .close ()
798842
799843 app .main_session .app .after_render += close_input
844+ original_pager = command_toolbar .Pager
845+
846+ def pager (* args , ** kwargs ):
847+ instance = original_pager (* args , ** kwargs )
848+
849+ def expired (timeout = None ):
850+ assert timeout is not None
851+ display = app ._command_toolbar
852+ display ._thread .join (5 )
853+ assert not display ._thread .is_alive ()
854+ assert not instance .closed .is_set ()
855+ return False
856+
857+ monkeypatch .setattr (instance .closed , "wait" , expired )
858+ return instance
859+
860+ monkeypatch .setattr (command_toolbar , "Pager" , pager )
800861 with pytest .raises (EOFError ), app ._command_toolbar_context ():
801862 app ._command_toolbar .page ("line\n " * 100 , chop = False )
802863 assert app .main_session .app .layout is layout
@@ -818,15 +879,63 @@ def test_builtin_pager_does_not_capture_redirected_output(toolbar_app, monkeypat
818879 assert "Cmd2 Commands" not in output .getvalue ()
819880
820881
821- def test_command_toolbar_startup_does_not_wait_forever (toolbar_app , monkeypatch , capsys ) -> None :
882+ @pytest .fixture
883+ def expire_startup (monkeypatch ):
884+ """Expire only this display's readiness wait, after its real render has started.
885+
886+ The five-second waits are failure watchdogs, not simulated startup delays. Keep
887+ blocked workers alive for ownership assertions and join them before fixture teardown.
888+ """
889+ displays = []
890+ releases = []
891+
892+ def install (app , * , block = True ):
893+ entered = threading .Event ()
894+ release = threading .Event ()
895+ releases .append (release )
896+ original_init = command_toolbar .CommandToolbar .__init__
897+
898+ def toolbar ():
899+ entered .set ()
900+ if block :
901+ assert release .wait (5 ), "test never released the display"
902+ return "STATUS"
903+
904+ def init (display , * args , ** kwargs ):
905+ original_init (display , * args , ** kwargs )
906+ displays .append (display )
907+
908+ def expired (timeout = None ):
909+ assert entered .wait (5 ), "display never entered its render callback"
910+ assert timeout is not None , "startup must bound its readiness wait"
911+ return False
912+
913+ monkeypatch .setattr (display ._ready , "wait" , expired )
914+
915+ app .main_session .bottom_toolbar = toolbar
916+ monkeypatch .setattr (command_toolbar .CommandToolbar , "__init__" , init )
917+ if block :
918+ monkeypatch .setattr (command_toolbar , "_SHUTDOWN_TIMEOUT" , 0.01 )
919+ return release
920+
921+ yield install
922+ for release in releases :
923+ release .set ()
924+ for display in displays :
925+ if display ._thread is not None :
926+ display ._thread .join (5 )
927+ assert not display ._thread .is_alive ()
928+
929+
930+ def test_command_toolbar_startup_does_not_wait_forever (toolbar_app , monkeypatch , expire_startup , capsys ) -> None :
822931 """A display that never reports itself started must not hold the command thread.
823932
824933 The readiness signal comes from the display's own thread, so anything that stops it
825934 arriving -- a render that never completes, a frame skipped forever -- would otherwise
826935 block the command that is waiting to run.
827936 """
828937 app , _ , _ = toolbar_app
829- monkeypatch . setattr ( command_toolbar , "_STARTUP_TIMEOUT" , 0.2 )
938+ expire_startup ( app , block = False )
830939 monkeypatch .setattr (command_toolbar .CommandToolbar , "_display_started" , lambda * args : None )
831940 monkeypatch .setattr (command_toolbar .CommandToolbar , "_display_started_without_app" , lambda * args : None )
832941
@@ -838,7 +947,7 @@ def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch,
838947 assert "did not start" in capsys .readouterr ().err
839948
840949
841- def test_command_toolbar_startup_timeout_does_not_block_on_cleanup (toolbar_app , monkeypatch , capsys ) -> None :
950+ def test_command_toolbar_startup_timeout_does_not_block_on_cleanup (toolbar_app , expire_startup , capsys ) -> None :
842951 """A blocked render callback must not hold the command thread through teardown either.
843952
844953 The readiness wait being bounded is only half of it: the display thread is still inside
@@ -847,15 +956,8 @@ def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app,
847956 why it could not see this.
848957 """
849958 app , _ , _ = toolbar_app
850- blocked = threading .Event ()
851- monkeypatch .setattr (command_toolbar , "_STARTUP_TIMEOUT" , 0.2 )
852- monkeypatch .setattr (command_toolbar , "_SHUTDOWN_TIMEOUT" , 0.01 )
853-
854- def blocking_toolbar () -> str :
855- blocked .wait (timeout = 10 )
856- return "STATUS"
959+ blocked = expire_startup (app )
857960
858- app .main_session .bottom_toolbar = blocking_toolbar
859961 ran = []
860962 try :
861963 started = time .monotonic ()
@@ -958,16 +1060,13 @@ def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app,
9581060 blocked .set ()
9591061
9601062
961- def test_a_surviving_display_blocks_later_handoffs (toolbar_app , monkeypatch ) -> None :
1063+ def test_a_surviving_display_blocks_later_handoffs (toolbar_app , expire_startup ) -> None :
9621064 """Disabling future displays is not enough: the old one still owns the terminal."""
9631065 app , _ , _ = toolbar_app
964- blocked = threading .Event ()
965- monkeypatch .setattr (command_toolbar , "_STARTUP_TIMEOUT" , 0.2 )
966- monkeypatch .setattr (command_toolbar , "_SHUTDOWN_TIMEOUT" , 0.01 )
1066+ blocked = expire_startup (app )
9671067 entered = []
9681068
9691069 try :
970- app .main_session .bottom_toolbar = lambda : blocked .wait (timeout = 10 ) or "STATUS"
9711070 with app ._command_toolbar_context ():
9721071 pass
9731072
@@ -980,15 +1079,12 @@ def test_a_surviving_display_blocks_later_handoffs(toolbar_app, monkeypatch) ->
9801079 blocked .set ()
9811080
9821081
983- def test_a_surviving_display_blocks_the_prompt (toolbar_app , monkeypatch ) -> None :
1082+ def test_a_surviving_display_blocks_the_prompt (toolbar_app , expire_startup ) -> None :
9841083 """Two readers on one terminal is not a state to keep prompting in."""
9851084 app , _ , _ = toolbar_app
986- blocked = threading .Event ()
987- monkeypatch .setattr (command_toolbar , "_STARTUP_TIMEOUT" , 0.2 )
988- monkeypatch .setattr (command_toolbar , "_SHUTDOWN_TIMEOUT" , 0.01 )
1085+ blocked = expire_startup (app )
9891086
9901087 try :
991- app .main_session .bottom_toolbar = lambda : blocked .wait (timeout = 10 ) or "STATUS"
9921088 with app ._command_toolbar_context ():
9931089 pass
9941090
@@ -998,23 +1094,20 @@ def test_a_surviving_display_blocks_the_prompt(toolbar_app, monkeypatch) -> None
9981094 blocked .set ()
9991095
10001096
1001- def test_the_refusal_lifts_when_the_display_finally_exits (toolbar_app , monkeypatch ) -> None :
1097+ def test_the_refusal_lifts_when_the_display_finally_exits (toolbar_app , expire_startup ) -> None :
10021098 """The thread may yet finish, and the session should not stay broken if it does.
10031099
10041100 Lifting the refusal is not the whole of it. The pause that timed out never restored the
10051101 application it had borrowed, so the prompt that comes next would render with the command
10061102 display's layout and key bindings unless that teardown is finished first.
10071103 """
10081104 app , _ , _ = toolbar_app
1009- blocked = threading .Event ()
1010- monkeypatch .setattr (command_toolbar , "_STARTUP_TIMEOUT" , 0.2 )
1011- monkeypatch .setattr (command_toolbar , "_SHUTDOWN_TIMEOUT" , 0.01 )
1105+ blocked = expire_startup (app )
10121106
10131107 prompt_layout = app .main_session .app .layout
10141108 prompt_bindings = app .main_session .app .key_bindings
10151109 prompt_erase = app .main_session .app .erase_when_done
10161110
1017- app .main_session .bottom_toolbar = lambda : blocked .wait (timeout = 10 ) or "STATUS"
10181111 with app ._command_toolbar_context ():
10191112 pass
10201113 surviving = app ._display_holding_terminal
@@ -1033,14 +1126,11 @@ def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, monkeypat
10331126 assert app .main_session .app .erase_when_done == prompt_erase
10341127
10351128
1036- def test_a_surviving_display_stops_another_from_starting (toolbar_app , monkeypatch ) -> None :
1129+ def test_a_surviving_display_stops_another_from_starting (toolbar_app , expire_startup ) -> None :
10371130 app , _ , _ = toolbar_app
1038- blocked = threading .Event ()
1039- monkeypatch .setattr (command_toolbar , "_STARTUP_TIMEOUT" , 0.2 )
1040- monkeypatch .setattr (command_toolbar , "_SHUTDOWN_TIMEOUT" , 0.01 )
1131+ blocked = expire_startup (app )
10411132
10421133 try :
1043- app .main_session .bottom_toolbar = lambda : blocked .wait (timeout = 10 ) or "STATUS"
10441134 with app ._command_toolbar_context ():
10451135 pass
10461136 with app ._command_toolbar_context ():
0 commit comments