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