Skip to content

Commit cf008d1

Browse files
authored
Merge pull request #1757 from python-cmd2/stage2b-serialization-painter
Stage 2b: serialization, painter and renderer recovery
2 parents d67e9c0 + 0dc711e commit cf008d1

9 files changed

Lines changed: 3982 additions & 0 deletions

‎cmd2/output_recorder.py‎

Lines changed: 415 additions & 0 deletions
Large diffs are not rendered by default.

‎cmd2/prompt_toolkit_bridge.py‎

Lines changed: 643 additions & 0 deletions
Large diffs are not rendered by default.

‎cmd2/terminal_transaction.py‎

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""The terminal transaction lock and the wait contract that keeps it deadlock-free.
2+
3+
Every byte cmd2 sends to the terminal -- a renderer frame replayed by the bridge, a toolbar
4+
paint, a managed write, a margin change -- is emitted inside one *terminal transaction*.
5+
Serializing individual output methods is not enough: a paint that lands between a renderer's
6+
cursor move and its text write puts the text somewhere other than where the renderer meant
7+
it, and both calls were individually locked.
8+
9+
:class:`TerminalLock` is ``L_terminal``: the last lock in the output path. The rules it
10+
enforces are the ones that make "last" true.
11+
12+
**Nothing higher-level may be held while taking it.** Stream routing locks, ownership and
13+
lifecycle locks, queue locks -- all are released first. :class:`HigherLevelLock` records that
14+
a thread holds one, so taking the terminal lock underneath it is refused *before* a real
15+
blocking acquire rather than discovered as a deadlock.
16+
17+
**Nothing that can wait may run while holding it.** No application callback, no proxy drain,
18+
no future or event or queue wait, no join, no sleep. :func:`guarded_call` refuses those
19+
synchronously, before the primitive is entered; a violation that fires after the wait has
20+
begun protects nothing.
21+
22+
The unavoidable exception is leaf I/O. ``write()``, ``flush()`` and native console calls can
23+
block on the operating system, and they belong inside the boundary precisely because they are
24+
the emission. Isolating them behind the physical backend keeps them from calling back into
25+
cmd2; it does not make them non-blocking, and this module claims no such thing.
26+
27+
The guard is per-thread and always on. Its cost is a thread-local attribute read, which is
28+
cheaper than the class of bug it catches is to diagnose from a hung terminal.
29+
"""
30+
31+
import threading
32+
from collections.abc import Callable, Iterator
33+
from contextlib import contextmanager
34+
from dataclasses import dataclass
35+
from types import TracebackType
36+
from typing import Any, Protocol, Self, TypeVar
37+
38+
_T = TypeVar("_T")
39+
40+
41+
class TerminalTransactionViolationError(RuntimeError):
42+
"""Raised when the lock or wait contract would be broken.
43+
44+
This is deliberately an error rather than a warning. The operations it guards deadlock or
45+
corrupt the display when they are allowed through, and both failures are far harder to
46+
attribute after the fact than an exception at the call site.
47+
48+
The design calls this ``TerminalTransactionViolation``; the ``Error`` suffix is the
49+
repository's naming rule for exceptions.
50+
"""
51+
52+
53+
class _Lock(Protocol):
54+
"""The subset of a lock this module uses, so tests can supply a non-blocking double."""
55+
56+
def acquire(self, *args: Any, **kwargs: Any) -> bool:
57+
"""Take the lock."""
58+
... # pragma: no cover
59+
60+
def release(self) -> None:
61+
"""Give the lock back."""
62+
... # pragma: no cover
63+
64+
65+
@dataclass(frozen=True)
66+
class TransactionState:
67+
"""What the debug guard records about the transaction a thread is inside."""
68+
69+
#: What the transaction is for, for diagnostics: ``"paint"``, ``"commit"``, and so on.
70+
kind: str
71+
72+
#: How many nested emission helpers are sharing this transaction.
73+
depth: int
74+
75+
#: The thread that owns it. A transaction is never visible to another thread.
76+
thread_id: int
77+
78+
#: The geometry generation the transaction validated against, where it has one.
79+
generation: int | None = None
80+
81+
82+
class _GuardState(threading.local):
83+
"""Per-thread record of the transaction and the higher-level locks this thread holds."""
84+
85+
def __init__(self) -> None:
86+
self.transaction: TransactionState | None = None
87+
self.held_locks: list[str] = []
88+
89+
90+
_state = _GuardState()
91+
92+
93+
def current_transaction() -> TransactionState | None:
94+
"""Report the terminal transaction this thread is inside, if any.
95+
96+
:return: the active transaction state, or ``None``
97+
"""
98+
return _state.transaction
99+
100+
101+
def held_higher_level_locks() -> tuple[str, ...]:
102+
"""Report the higher-level locks this thread holds, outermost first.
103+
104+
:return: the names of the held locks
105+
"""
106+
return tuple(_state.held_locks)
107+
108+
109+
def assert_no_terminal_transaction(operation: str) -> None:
110+
"""Refuse an operation that must not run inside a terminal transaction.
111+
112+
Call this *before* every blocking helper, callback dispatch, proxy drain or close, and
113+
join that cmd2 owns -- not after, and not inside the primitive.
114+
115+
:param operation: what was about to happen, named for the error message
116+
:raises TerminalTransactionViolationError: if this thread is inside a transaction
117+
"""
118+
active = _state.transaction
119+
if active is not None:
120+
raise TerminalTransactionViolationError(
121+
f"{operation} is not allowed inside the {active.kind} terminal transaction "
122+
f"(depth {active.depth}); release the terminal lock first"
123+
)
124+
125+
126+
def guarded_call(operation: str, func: Callable[..., _T], *args: Any, **kwargs: Any) -> _T:
127+
"""Run a call that may block, refusing it inside a terminal transaction.
128+
129+
:param operation: what the call is, named for the error message
130+
:param func: the callable to run
131+
:param args: positional arguments for ``func``
132+
:param kwargs: keyword arguments for ``func``
133+
:return: whatever ``func`` returns
134+
:raises TerminalTransactionViolationError: if this thread is inside a transaction
135+
"""
136+
assert_no_terminal_transaction(operation)
137+
return func(*args, **kwargs)
138+
139+
140+
class HigherLevelLock:
141+
"""A lock that ranks above ``L_terminal`` and must be released before it is taken.
142+
143+
Stream routing, ownership and lifecycle, application state and work queues all live here.
144+
Holding one while acquiring the terminal lock is the deadlock: the thread holding the
145+
terminal lock cannot finish emitting until a worker gets the routing lock back, and the
146+
worker cannot until the emitter gives it up.
147+
"""
148+
149+
def __init__(self, name: str, lock: _Lock | None = None) -> None:
150+
"""Wrap a lock under a name that appears in violation messages.
151+
152+
:param name: what this lock protects, for diagnostics
153+
:param lock: the lock to wrap; a fresh :class:`threading.RLock` by default
154+
"""
155+
self._name = name
156+
self._lock: _Lock = lock if lock is not None else threading.RLock()
157+
158+
@property
159+
def name(self) -> str:
160+
"""What this lock protects."""
161+
return self._name
162+
163+
def __enter__(self) -> Self:
164+
"""Take the lock, refusing to do so from inside a terminal transaction.
165+
166+
:return: this lock
167+
:raises TerminalTransactionViolationError: if this thread is inside a transaction
168+
"""
169+
assert_no_terminal_transaction(f"acquiring the {self._name} lock")
170+
self._lock.acquire()
171+
_state.held_locks.append(self._name)
172+
return self
173+
174+
def __exit__(
175+
self,
176+
exc_type: type[BaseException] | None,
177+
exc_value: BaseException | None,
178+
traceback: TracebackType | None,
179+
) -> None:
180+
"""Give the lock back, including when the body raised."""
181+
_state.held_locks.pop()
182+
self._lock.release()
183+
184+
185+
class TerminalLock:
186+
"""``L_terminal``: the final lock in the output path.
187+
188+
Re-entrant on one thread so that an emission helper may call another, but a nested
189+
transaction joins the outer one rather than starting its own -- the outer transaction's
190+
kind and generation are what a violation message should name, because the outer one is
191+
what validated against the terminal.
192+
"""
193+
194+
def __init__(self, lock: _Lock | None = None) -> None:
195+
"""Build a terminal lock.
196+
197+
:param lock: the lock to serialize on; a fresh :class:`threading.RLock` by default
198+
"""
199+
self._lock: _Lock = lock if lock is not None else threading.RLock()
200+
201+
@property
202+
def active(self) -> bool:
203+
"""Whether this thread is currently inside a terminal transaction."""
204+
return _state.transaction is not None
205+
206+
@contextmanager
207+
def transaction(self, kind: str, generation: int | None = None) -> Iterator[TransactionState]:
208+
"""Hold the terminal for the duration of one transaction.
209+
210+
:param kind: what the transaction is for, for diagnostics
211+
:param generation: the geometry generation it was validated against, if any
212+
:return: a context manager yielding the transaction state
213+
:raises TerminalTransactionViolationError: if this thread holds a higher-level lock
214+
"""
215+
active = _state.transaction
216+
if active is not None:
217+
nested = TransactionState(
218+
kind=active.kind,
219+
depth=active.depth + 1,
220+
thread_id=active.thread_id,
221+
generation=active.generation,
222+
)
223+
_state.transaction = nested
224+
try:
225+
yield nested
226+
finally:
227+
_state.transaction = active
228+
return
229+
230+
if _state.held_locks:
231+
held = ", ".join(_state.held_locks)
232+
raise TerminalTransactionViolationError(
233+
f"cannot start the {kind} terminal transaction while holding {held}; "
234+
f"higher-level locks are released before the terminal lock is taken"
235+
)
236+
237+
self._lock.acquire()
238+
state = TransactionState(kind=kind, depth=1, thread_id=threading.get_ident(), generation=generation)
239+
_state.transaction = state
240+
try:
241+
yield state
242+
finally:
243+
_state.transaction = None
244+
self._lock.release()

0 commit comments

Comments
 (0)