Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
- History regular expressions containing range separators are now handled as regex searches.
Other selectors are treated as numbers or ranges only when they match the supported syntax;
strings such as URLs, `a:b`, `+1`, and `1_0` remain literal searches.
- Fixed `set_window_title()` leaving title changes buffered until the next prompt render.
Updates during a running prompt are sent through its event loop to avoid concurrent writes
from background threads.

## 4.3.1 (October 24, 2026)

Expand Down
58 changes: 52 additions & 6 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
filters,
print_formatted_text,
)
from prompt_toolkit.application import create_app_session, get_app
from prompt_toolkit.application import create_app_session, get_app, get_app_session
from prompt_toolkit.application.current import AppSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import Completer, DummyCompleter
from prompt_toolkit.formatted_text import ANSI, AnyFormattedText
Expand All @@ -84,7 +85,7 @@
from prompt_toolkit.layout.containers import ConditionalContainer, FloatContainer
from prompt_toolkit.output import DummyOutput, create_output
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice, set_title
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession, choice
from prompt_toolkit.styles import DynamicStyle
from rich.console import (
Group,
Expand Down Expand Up @@ -220,6 +221,11 @@ def __init__(self, msg: str = "") -> None:
from prompt_toolkit.buffer import Buffer


# select() creates a child session that ordinary background threads cannot see through contextvars.
_select_app_sessions: dict[AppSession, list[AppSession]] = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the routing could be simpler.

The global dict, the lock and the walk from parent to child session exist to work around select() using its own private create_app_session.

Consider routing per instance instead, for example by recording the active choice() app on the Cmd object or using self.main_session.app. That would fix some related problems and might remove the need for a lot of the threaded tests.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at this first, because if this is done right, I think it probably eliminates about 3 of the other issues I commented on.

_select_app_sessions_lock = threading.Lock()


class _SavedCmd2Env:
"""cmd2 environment settings that are backed up when entering an interactive Python shell."""

Expand Down Expand Up @@ -4792,10 +4798,20 @@ def select(self, opts: str | Iterable[str] | Iterable[tuple[Any, str | None]], p
fulloptions.append((opt[0], str(opt[0])))

if self._is_tty_session(self.main_session):
parent_session = get_app_session()
try:
while True:
with create_app_session(input=self.main_session.input, output=self.main_session.output):
result = choice(message=prompt, options=fulloptions)
with create_app_session(input=self.main_session.input, output=self.main_session.output) as session:
with _select_app_sessions_lock:
sessions = _select_app_sessions.setdefault(parent_session, [])
sessions.append(session)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A session can be left behind in the registry.

select() calls sessions.append(session) before entering its try/finally. A KeyboardInterrupt in that gap leaves a dead child session in _select_app_sessions. After that, every title update goes to it and is written directly from the calling thread, for the rest of the process.

try:
result = choice(message=prompt, options=fulloptions)
finally:
with _select_app_sessions_lock:
sessions.remove(session)
if not sessions:
del _select_app_sessions[parent_session]
if result is not None:
return result
except KeyboardInterrupt:
Expand Down Expand Up @@ -5884,12 +5900,42 @@ def add_alert(
self._alert_condition.notify_all()

@staticmethod
def set_window_title(title: str) -> None: # pragma: no cover
def set_window_title(title: str) -> None:
"""Set the terminal window title.

When a prompt-toolkit application is running, update the title on its event loop
to avoid writing to its output buffer from a background thread.
Calls use the current prompt-toolkit app session, including any active select() menu.
Worker threads serving a custom app session must inherit that session's context.

:param title: the new window title
"""
set_title(title)
session = get_app_session()
with _select_app_sessions_lock:
Comment thread
tleonhardt marked this conversation as resolved.
# Workers may still see the parent session instead of select()'s temporary one.
# Follow registered children to the innermost active select() session.
# [-1] chooses the most recently registered active child when menus share a parent.
while session in _select_app_sessions:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One Cmd instance can set another's title.

_select_app_sessions is process-wide, so a worker thread in instance A can write A's title to instance B's terminal while B is in select(). The new out-of-order test actually asserts this behavior.

session = _select_app_sessions[session][-1]
app = session.app
output = app.output if app is not None else session.output

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Titles can go to two different terminals.

While a prompt or select() is running, the title goes to app.output, which is built from self.stdout. While a command is running, it goes to the default session's output, which comes from sys.stdout. If those are different TTYs, titles alternate between them.


def set_title_and_flush() -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors no longer reach the caller.

When the write is deferred to the loop, an OSError from set_title/flush is raised inside the loop instead of in the caller. The contextlib.suppress(OSError) in examples/textual_tty_embed.py then has no effect, and prompt_toolkit prints "Unhandled exception in event loop".

output.set_title(title)
output.flush()

loop = app.loop if app is not None else None
if loop is not None and loop.is_running():
try:
loop.call_soon_threadsafe(set_title_and_flush)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A title can be dropped when the prompt exits.

There is a gap between the loop.is_running() check and call_soon_threadsafe. If the loop stops in that gap, the callback is queued on a stopped loop: under asyncio.run` teardown it never runs, and under IPython's reused loop it runs only at the next prompt.

except RuntimeError:
# The application can finish and close its loop between the check and scheduling.
if not loop.is_closed():
raise
else:
return

set_title_and_flush()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback path can still race the render loop).

When session.app is None, a worker thread writes and flushes the shared Vt100_Output directly. That happens just before a prompt starts, and in select() between registering the session and choice() building its app. Vt100_Output.flush() does ''.join(self._buffer); self._buffer = [], so render data added between those two steps is lost. This is the same race the PR set out to fix, and on free-threaded 3.14t the two steps really do run in parallel.


def enable_command(self, command: str) -> None:
"""Enable a command by restoring its functions.
Expand Down
8 changes: 8 additions & 0 deletions docs/features/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ Alerts can interact with the CLI in two ways:

- [cmd2.Cmd.set_window_title][]

Title changes are flushed without waiting for the next prompt. When a prompt-toolkit application is
running, the update is scheduled on its event loop so background threads can change the title
without writing to the prompt's output buffer directly. The title is sent to that application's
output, including while a `select()` menu is active.

Calls use the current prompt-toolkit app session. If you create a custom session, pass its context
to worker threads, for example with `contextvars.copy_context().run`.

The easiest way to understand these functions is to see the
[async_printing.py](https://github.com/python-cmd2/cmd2/blob/main/examples/async_printing.py)
example for a demonstration.
Expand Down
Loading
Loading