Skip to content

Commit cb61617

Browse files
committed
Speed up toolbar tests with explicit synchronization
Replace fixed sleeps and barrier-expiration checks with observed lock contention and controlled timeout expiration. Keep real display threads, subprocess output, and ownership assertions; reuse the running pipe fixture to avoid paying the subprocess startup probe in toolbar pipe tests. Wait for render callbacks to start before expiring readiness, and join blocked workers during fixture cleanup. Exercise pending UI calls and pager EOF without waiting for polling intervals. Full-suite timings on the same local Python 3.14 environment: | Execution | Before | After | |------------------------|--------|-------| | Parallel with coverage | 4.92s | 3.74s | | Serial with coverage | 11.87s | 8.96s | Validation: 2531 passed, 6 skipped; 1770 repeated parallel tests passed. Four mutations were killed. make check and make docs-test passed.
1 parent 61d2969 commit cb61617

4 files changed

Lines changed: 201 additions & 111 deletions

File tree

‎tests/conftest.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import subprocess
66
import sys
7+
import threading
78
from collections.abc import Callable
89
from contextlib import redirect_stderr
910
from typing import (
@@ -291,3 +292,28 @@ def toolbar_app():
291292
refresh_interval=0.01,
292293
)
293294
yield app, pipe, output
295+
296+
297+
class ContendedLock:
298+
"""A real reentrant lock that reports a competing acquisition without a sleep."""
299+
300+
def __init__(self) -> None:
301+
self._lock = threading.RLock()
302+
self.contended = threading.Event()
303+
304+
def acquire(self) -> bool:
305+
if self._lock.acquire(blocking=False):
306+
return True
307+
self.contended.set()
308+
assert self._lock.acquire(timeout=5), "owner never released the lock"
309+
return True
310+
311+
def release(self) -> None:
312+
self._lock.release()
313+
314+
def __enter__(self):
315+
self.acquire()
316+
return self
317+
318+
def __exit__(self, *args):
319+
self.release()

‎tests/test_command_toolbar.py‎

Lines changed: 141 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from cmd2 import Cmd, ToolbarMode, command_toolbar
2121

22-
from .conftest import RecordingOutput, Terminal
22+
from .conftest import ContendedLock, RecordingOutput, Terminal
2323

2424

2525
def 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

445479
def 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

Comments
 (0)