oc_chat
Loading...
Searching...
No Matches
event_bus.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/event_bus.py
4
5"""
6In-process publish/subscribe event bus — M2.1 (event substrate).
7
8Purpose
9-------
10A tiny, synchronous pub/sub bus that the M2.x stack wires together: the React
11loop and sub-loop (M2.3/M2.4) publish ``TurnEvent`` envelopes; the NDJSON
12writer (M2.2), ``/trace`` and ``/packet`` renderers (M2.6), and the replay CLI
13(M2.7) subscribe. M2.1 ships only the surface — nothing here imports anything
14writer- or loop-shaped.
15
16Responsibilities
17----------------
18- Provide ``EventBus`` with ``subscribe`` / ``unsubscribe`` / ``publish`` and a
19 ``Subscription`` handle.
20- Dispatch synchronously and thread-safely, isolating subscriber exceptions.
21- Provide the lazily-constructed ``default_bus()`` convenience singleton plus a
22 test-only ``_reset_default_bus_for_tests()`` hook.
23
24Semantics (see plan 095 §5 ADRs)
25--------------------------------
26- ADR-1: ``publish`` is synchronous — callbacks run on the caller's thread,
27 before ``publish`` returns. Events are observability, not control flow.
28- ADR-3: each callback runs under ``try / except Exception`` — a buggy
29 subscriber is logged and skipped, never propagated. ``KeyboardInterrupt`` and
30 ``SystemExit`` are intentionally NOT caught so they still escape.
31- ADR-4: a lazily-constructed module-level default bus (``default_bus()``) is a
32 convenience for the common one-bus-per-process case; every consumer can also
33 accept an injected ``EventBus`` for isolation (tests, sub-agent scopes).
34
35Dispatch order: for a published event, kind-specific subscribers fire first (in
36subscription order), then ``"*"`` wildcard subscribers (in subscription order).
37``publish`` snapshots the subscriber lists under a lock and releases the lock
38before invoking callbacks, so a callback may safely subscribe/unsubscribe
39without deadlocking — the change takes effect on the *next* publish.
40
41Diagnostics
42-----------
43Domain: OC_CHAT.MAIN
44Levels:
45 L2 — subscriber callback raised (lifecycle-worthy at default verbosity).
46 L3 — unsubscribe of an unknown handle (no-op).
47 L4 — module-load coverage marker.
48
49Contracts
50---------
51- ``publish`` is synchronous and never propagates subscriber ``Exception``s;
52 ``KeyboardInterrupt`` / ``SystemExit`` are intentionally re-raised.
53- Subscriber lists are snapshotted under a lock before dispatch, so callbacks
54 may subscribe/unsubscribe during dispatch without effect on the in-flight
55 event.
56- ``default_bus()`` is a convenience only; every consumer also accepts an
57 injected ``EventBus``.
58- **Publisher topology (NARROWED, plan 152 R11-02).** The thread-safety contract
59 above is about internal consistency, NOT a promise that arbitrary threads
60 publish concurrently. In production every ``default_bus().publish(`` call site
61 lives on the MAIN thread inside ``run_turn`` (``react_loop.py`` and
62 ``sub_loop.py`` — the only two authorised publisher modules; zero publishers
63 exist under ``oc_chat/tools/``, so a tool worker outliving a hard-cancelled
64 turn still cannot publish). The bus is therefore QUIESCENT between turns by
65 design. ``replace_subscription`` relies on exactly this topology: it swaps a
66 subscription atomically for callers that hold the between-turns main-thread
67 lease, and does NOT drain a publisher that is already mid-dispatch against a
68 pre-swap snapshot. A static guard test pins the publisher call sites; adding a
69 publisher outside those two modules — or on another thread — breaks that test
70 and re-opens the retirement-handle question tracked in plan ``152-b``.
71
72Dependencies
73------------
74- oc_diagnostics (structured debug logging via _dbg)
75- oc_chat.core.events (TurnEvent / EventKind types, via the contracts bootstrap)
76"""
77from __future__ import annotations
78
79import itertools
80import threading
81from dataclasses import dataclass, field
82from typing import Callable, Dict, List, Literal, Optional, Union
83
84from oc_diagnostics import _dbg
85
86# Importing from the sibling module reuses its contracts-import bootstrap, so we
87# never duplicate the sys.path shim here.
88from oc_chat.core.events import EventKind, TurnEvent
89
90_dbg("OC_CHAT.MAIN", "event_bus", "module", 4)
91
92# The wildcard subscription key: receives every published event.
93WILDCARD: Literal["*"] = "*"
94
95SubscriptionKey = Union[EventKind, Literal["*"]]
96Callback = Callable[[TurnEvent], None]
97
98
99@dataclass
101 """Opaque handle returned by :meth:`EventBus.subscribe`.
102
103 ``seq`` is a process-monotonic counter that gives deterministic dispatch
104 order within a key even when two subscriptions compare equal otherwise.
105 """
106
107 kind: str
108 callback: Callback
109 seq: int = field(default=0)
110
111
113 """A synchronous, thread-safe, in-process pub/sub bus for ``TurnEvent``s."""
114
115 def __init__(self) -> None:
116 self._subscribers: Dict[str, List[Subscription]] = {}
117 self._lock = threading.RLock()
118 self._seq = itertools.count()
119
120 def subscribe(self, kind: SubscriptionKey, callback: Callback) -> Subscription:
121 """Register ``callback`` for ``kind`` (an ``EventKind`` or ``"*"``).
122
123 Returns a :class:`Subscription` handle; pass it to :meth:`unsubscribe`
124 to remove the registration.
125 """
126 with self._lock:
127 sub = Subscription(kind=str(kind), callback=callback, seq=next(self._seq))
128 self._subscribers.setdefault(str(kind), []).append(sub)
129 return sub
130
131 def unsubscribe(self, subscription: Subscription) -> None:
132 """Remove a previously returned handle. Unknown handles are a no-op (logged at L3)."""
133 with self._lock:
134 bucket = self._subscribers.get(subscription.kind)
135 if bucket is not None:
136 for i, existing in enumerate(bucket):
137 if existing is subscription:
138 del bucket[i]
139 if not bucket:
140 del self._subscribers[subscription.kind]
141 return
142 func = "unsubscribe"
143 _dbg(
144 "OC_CHAT.MAIN",
145 func,
146 f"ignored unknown handle kind={subscription.kind!r} seq={subscription.seq}",
147 3,
148 )
149
151 self,
152 old: Subscription | None,
153 kind: SubscriptionKey,
154 callback: Callback,
155 ) -> Subscription:
156 """Atomically swap *old* for a new ``kind``/``callback`` registration.
157
158 Remove-then-add under ONE acquisition of the bus lock, so a concurrent
159 :meth:`publish` — which snapshots the subscriber lists under the same
160 lock before dispatching outside it — observes EXACTLY the old
161 registration or EXACTLY the new one, never both and never neither. That
162 is the property the session-transition event-writer swap needs (plan 152
163 ADR-6): a wildcard writer must never be duplicated or momentarily absent.
164
165 *old* may be ``None`` (nothing to remove) or an unknown handle (removal
166 is a no-op, matching :meth:`unsubscribe`). The new subscription is
167 appended in the normal way, so dispatch order is unchanged.
168
169 This is a lock-protected list mutation only — it performs no I/O and
170 cannot fail, which is what lets it sit inside a no-fail commit block.
171 See the module Contracts note on publisher topology: this method does not
172 drain a publisher already mid-dispatch against a pre-swap snapshot,
173 because production publishers are main-thread and between-turns
174 quiescent.
175 """
176 func = "replace_subscription"
177 with self._lock:
178 if old is not None:
179 bucket = self._subscribers.get(old.kind)
180 if bucket is not None:
181 for i, existing in enumerate(bucket):
182 if existing is old:
183 del bucket[i]
184 if not bucket:
185 del self._subscribers[old.kind]
186 break
187 sub = Subscription(kind=str(kind), callback=callback, seq=next(self._seq))
188 self._subscribers.setdefault(str(kind), []).append(sub)
189 _dbg("OC_CHAT.MAIN", func, f"swapped kind={str(kind)!r} seq={sub.seq}", 3)
190 return sub
191
192 def publish(self, event: TurnEvent) -> None:
193 """Dispatch ``event`` to matching subscribers synchronously.
194
195 Snapshots the kind-specific and wildcard subscriber lists under the lock,
196 releases the lock, then invokes callbacks (kind-specific first, then
197 wildcard, each in subscription order). Subscriber exceptions are caught
198 and logged; ``KeyboardInterrupt`` / ``SystemExit`` propagate.
199 """
200 with self._lock:
201 specific = list(self._subscribers.get(str(event.kind), ()))
202 wildcard = list(self._subscribers.get(WILDCARD, ()))
203
204 for sub in specific:
205 self._dispatch_one(sub, event)
206 for sub in wildcard:
207 self._dispatch_one(sub, event)
208
209 @staticmethod
210 def _dispatch_one(sub: Subscription, event: TurnEvent) -> None:
211 func = "_dispatch_one"
212 try:
213 sub.callback(event)
214 except (KeyboardInterrupt, SystemExit):
215 raise
216 except Exception as exc: # noqa: BLE001 - bus must isolate subscriber faults
217 _dbg(
218 "OC_CHAT.MAIN",
219 func,
220 f"subscriber_error kind={sub.kind!r} event_kind={event.kind!r} "
221 f"seq={sub.seq} exc={type(exc).__name__}: {exc}",
222 2,
223 )
224
225
226_DEFAULT_BUS: Optional[EventBus] = None
227_DEFAULT_BUS_LOCK = threading.Lock()
228
229
230def default_bus() -> EventBus:
231 """Return the lazily-constructed process-wide default bus.
232
233 Callers wanting isolation (tests, sub-agent scopes) should construct their
234 own ``EventBus()`` and never touch this singleton.
235 """
236 global _DEFAULT_BUS
237 if _DEFAULT_BUS is None:
238 with _DEFAULT_BUS_LOCK:
239 if _DEFAULT_BUS is None:
240 _DEFAULT_BUS = EventBus()
241 return _DEFAULT_BUS
242
243
245 """Clear the default-bus singleton. Test-only — production code never calls this."""
246 global _DEFAULT_BUS
247 with _DEFAULT_BUS_LOCK:
248 _DEFAULT_BUS = None
249
250
251__all__ = [
252 "EventBus",
253 "Subscription",
254 "default_bus",
255 "WILDCARD",
256]
None _dispatch_one(Subscription sub, TurnEvent event)
Definition event_bus.py:210
Subscription replace_subscription(self, Subscription|None old, SubscriptionKey kind, Callback callback)
Definition event_bus.py:155
None publish(self, TurnEvent event)
Definition event_bus.py:192
Subscription subscribe(self, SubscriptionKey kind, Callback callback)
Definition event_bus.py:120
None unsubscribe(self, Subscription subscription)
Definition event_bus.py:131
None _reset_default_bus_for_tests()
Definition event_bus.py:244