Skip to content

Commit 7133c8d

Browse files
committed
Made the alert thread event-driven.
1 parent f6b6b41 commit 7133c8d

File tree

1 file changed

+67
-61
lines changed

1 file changed

+67
-61
lines changed

cmd2/cmd2.py

Lines changed: 67 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767

6868
import rich.box
6969
from prompt_toolkit import print_formatted_text
70+
from prompt_toolkit.application import get_app
7071
from rich.console import (
7172
Group,
7273
RenderableType,
@@ -608,11 +609,12 @@ def __init__(
608609
self._command_parsers: _CommandParsers = _CommandParsers(self)
609610

610611
# Members related to printing asychronous alerts
611-
self.alert_queue: queue.Queue[AsyncAlert] = queue.Queue()
612-
self._alerter_gate = threading.Event()
613-
self._alerter_shutdown = threading.Event()
614-
self._process_alerts_thread: threading.Thread | None = None
615-
self._prompt_drawn_at: float = 0.0 # Uses time.monotonic()
612+
self._alert_queue: queue.Queue[AsyncAlert] = queue.Queue()
613+
self._alert_condition = threading.Condition()
614+
self._alert_allowed = False
615+
self._alert_shutdown_event = threading.Event()
616+
self._alert_thread: threading.Thread | None = None
617+
self._alert_prompt_timestamp: float = 0.0 # Uses time.monotonic()
616618

617619
# Add functions decorated to be subcommands
618620
self._register_subcommands(self)
@@ -3351,40 +3353,34 @@ def read_input(
33513353
return self._read_raw_input(prompt, temp_session, completer_to_use)
33523354

33533355
def _process_alerts(self) -> None:
3354-
"""Background worker that processes queued alerts and prompt updates.
3356+
"""Background worker that processes queued alerts and dynamic prompt updates."""
3357+
while not self._alert_shutdown_event.is_set():
3358+
with self._alert_condition:
3359+
# Wait until alerts are allowed and available, or shutdown is signaled.
3360+
self._alert_condition.wait_for(
3361+
lambda: (not self._alert_queue.empty() and self._alert_allowed) or self._alert_shutdown_event.is_set()
3362+
)
33553363

3356-
This loop waits for the prompt gate to open, ensuring that background
3357-
messages and UI refreshes only occur while the user is at an
3358-
interactive prompt, avoiding interference with active commands.
3359-
"""
3360-
while not self._alerter_shutdown.is_set():
3361-
try:
3362-
# Wait for an alert
3363-
alert = self.alert_queue.get(timeout=0.1)
3364+
if self._alert_shutdown_event.is_set():
3365+
break
33643366

3365-
# Block if not at a prompt
3366-
while not self._alerter_gate.is_set():
3367-
if self._alerter_shutdown.is_set():
3368-
return
3369-
self._alerter_gate.wait(timeout=0.1)
3367+
# Get the next alert while still holding the condition lock.
3368+
alert = self._alert_queue.get()
33703369

3371-
# Print and update
3370+
if alert.msg:
3371+
# Print the message above the current prompt.
33723372
with patch_stdout():
3373-
if alert.msg:
3374-
print_formatted_text(pt_filter_style(alert.msg))
3375-
3376-
# Only update if the alert was generated after the current prompt was drawn on the screen.
3377-
if (alert.prompt is not None and
3378-
alert.prompt != self.prompt and
3379-
alert.timestamp > self._prompt_drawn_at): # fmt: skip
3380-
self.prompt = alert.prompt
3373+
print_formatted_text(pt_filter_style(alert.msg))
33813374

3382-
# Don't update the UI if we are at a continuation prompt.
3383-
if not self._at_continuation_prompt:
3384-
self.session.app.invalidate()
3375+
# Only apply prompt changes generated after the current prompt started.
3376+
if (alert.prompt is not None and
3377+
alert.prompt != self.prompt and
3378+
alert.timestamp > self._alert_prompt_timestamp): # fmt: skip
3379+
self.prompt = alert.prompt
33853380

3386-
except queue.Empty: # noqa: PERF203
3387-
continue
3381+
# Refresh UI immediately unless at a continuation prompt.
3382+
if not self._at_continuation_prompt:
3383+
get_app().invalidate()
33883384

33893385
def _read_command_line(self, prompt: str) -> str:
33903386
"""Read the next command line from the input stream.
@@ -3396,7 +3392,7 @@ def _read_command_line(self, prompt: str) -> str:
33963392
"""
33973393

33983394
# Use dynamic prompt if the prompt matches self.prompt
3399-
def get_prompt() -> ANSI:
3395+
def get_prompt() -> str | ANSI:
34003396
return pt_filter_style(self.prompt)
34013397

34023398
prompt_to_use: Callable[[], ANSI | str] | ANSI | str = ANSI(prompt)
@@ -3407,17 +3403,19 @@ def _pre_prompt() -> None:
34073403
"""Run standard pre-prompt processing and activate the background alerter."""
34083404
self.pre_prompt()
34093405

3410-
# Record exactly when the user was presented with this prompt
3411-
self._prompt_drawn_at = time.monotonic()
3406+
# Record when this prompt was started.
3407+
self._alert_prompt_timestamp = time.monotonic()
34123408

3413-
# Start alerter thread if it's not already running
3414-
if self._process_alerts_thread is None or not self._process_alerts_thread.is_alive():
3415-
self._alerter_shutdown.clear()
3416-
self._process_alerts_thread = threading.Thread(target=self._process_alerts, daemon=True)
3417-
self._process_alerts_thread.start()
3409+
# Start alerter thread if it's not already running.
3410+
if self._alert_thread is None or not self._alert_thread.is_alive():
3411+
self._alert_shutdown_event.clear()
3412+
self._alert_thread = threading.Thread(target=self._process_alerts, daemon=True)
3413+
self._alert_thread.start()
34183414

3419-
# Allow alerts to be printed
3420-
self._alerter_gate.set()
3415+
# Allow alerts to be printed now that we are at a prompt.
3416+
with self._alert_condition:
3417+
self._alert_allowed = True
3418+
self._alert_condition.notify_all()
34213419

34223420
try:
34233421
return self._read_raw_input(
@@ -3427,8 +3425,9 @@ def _pre_prompt() -> None:
34273425
pre_run=_pre_prompt,
34283426
)
34293427
finally:
3430-
# Ensure no alerts print while the command is processing
3431-
self._alerter_gate.clear()
3428+
# Ensure no alerts print while not at a prompt.
3429+
with self._alert_condition:
3430+
self._alert_allowed = False
34323431

34333432
def _cmdloop(self) -> None:
34343433
"""Repeatedly issue a prompt, accept input, parse it, and dispatch to apporpriate commands.
@@ -3457,16 +3456,17 @@ def _cmdloop(self) -> None:
34573456
stop = self.onecmd_plus_hooks(line)
34583457
finally:
34593458
with self.sigint_protection:
3460-
# Shut down the _process_alerts_thread
3461-
if self._process_alerts_thread is not None and self._process_alerts_thread.is_alive():
3462-
self._alerter_shutdown.set()
3463-
3464-
# Worker is a daemon polling every 0.1s. We join with a 1.0s
3465-
# safety timeout that is highly unlikely to be reached.
3466-
# If it is, the daemon status ensures the OS reaps the
3467-
# thread when the process exits rather than hanging.
3468-
self._process_alerts_thread.join(timeout=1.0)
3469-
self._process_alerts_thread = None
3459+
# Shut down the alert thread.
3460+
if self._alert_thread is not None:
3461+
with self._alert_condition:
3462+
self._alert_shutdown_event.set()
3463+
self._alert_condition.notify_all()
3464+
3465+
# The thread is event-driven and stays suspended until notified.
3466+
# We join with a 1 second timeout as a safety measure. If it hangs,
3467+
# the daemon status allows the OS to reap it on exit.
3468+
self._alert_thread.join(timeout=1.0)
3469+
self._alert_thread = None
34703470

34713471
#############################################################
34723472
# Parsers and functions for alias command and subcommands
@@ -5303,18 +5303,24 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None:
53035303
return self.do_run_script(su.quote(relative_path))
53045304

53055305
def add_alert(self, *, msg: str | None = None, prompt: str | None = None) -> None:
5306-
"""Thread-safe method to request UI updates.
5306+
"""Queue an asynchronous alert to be displayed when the prompt is active.
5307+
5308+
Examples:
5309+
add_alert(msg="System error!") # Print message only
5310+
add_alert(prompt="user@host> ") # Update prompt only
5311+
add_alert(msg="Done", prompt="> ") # Update both
53075312
53085313
:param msg: an optional message to be printed above the prompt.
53095314
:param prompt: an optional string to dynamically replace the current prompt.
53105315
5311-
1. print an alert: add_alert(msg="System error!")
5312-
2. print and update prompt: add_alert(msg="Logged in", prompt="user@host> ")
5313-
3. update prompt only: add_alert(prompt="waiting> ")
53145316
"""
5315-
if msg is not None or prompt is not None:
5317+
if msg is None and prompt is None:
5318+
return
5319+
5320+
with self._alert_condition:
53165321
alert = AsyncAlert(msg=msg, prompt=prompt)
5317-
self.alert_queue.put(alert)
5322+
self._alert_queue.put(alert)
5323+
self._alert_condition.notify_all()
53185324

53195325
@staticmethod
53205326
def set_window_title(title: str) -> None: # pragma: no cover

0 commit comments

Comments
 (0)