6In-process publish/subscribe event bus — M2.1 (event substrate).
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.
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.
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).
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.
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.
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
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``.
74- oc_diagnostics (structured debug logging via _dbg)
75- oc_chat.core.events (TurnEvent / EventKind types, via the contracts bootstrap)
77from __future__
import annotations
81from dataclasses
import dataclass, field
82from typing
import Callable, Dict, List, Literal, Optional, Union
84from oc_diagnostics
import _dbg
90_dbg(
"OC_CHAT.MAIN",
"event_bus",
"module", 4)
93WILDCARD: Literal[
"*"] =
"*"
95SubscriptionKey = Union[EventKind, Literal[
"*"]]
96Callback = Callable[[TurnEvent],
None]
101 """Opaque handle returned by :meth:`EventBus.subscribe`.
103 ``seq`` is a process-monotonic counter that gives deterministic dispatch
104 order within a key even when two subscriptions compare equal otherwise.
109 seq: int = field(default=0)
113 """A synchronous, thread-safe, in-process pub/sub bus for ``TurnEvent``s."""
120 def subscribe(self, kind: SubscriptionKey, callback: Callback) -> Subscription:
121 """Register ``callback`` for ``kind`` (an ``EventKind`` or ``"*"``).
123 Returns a :class:`Subscription` handle; pass it to :meth:`unsubscribe`
124 to remove the registration.
132 """Remove a previously returned handle. Unknown handles are a no-op (logged at L3)."""
135 if bucket
is not None:
136 for i, existing
in enumerate(bucket):
137 if existing
is subscription:
146 f
"ignored unknown handle kind={subscription.kind!r} seq={subscription.seq}",
152 old: Subscription |
None,
153 kind: SubscriptionKey,
156 """Atomically swap *old* for a new ``kind``/``callback`` registration.
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.
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.
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
176 func =
"replace_subscription"
180 if bucket
is not None:
181 for i, existing
in enumerate(bucket):
189 _dbg(
"OC_CHAT.MAIN", func, f
"swapped kind={str(kind)!r} seq={sub.seq}", 3)
193 """Dispatch ``event`` to matching subscribers synchronously.
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.
201 specific = list(self.
_subscribers.get(str(event.kind), ()))
211 func =
"_dispatch_one"
214 except (KeyboardInterrupt, SystemExit):
216 except Exception
as exc:
220 f
"subscriber_error kind={sub.kind!r} event_kind={event.kind!r} "
221 f
"seq={sub.seq} exc={type(exc).__name__}: {exc}",
226_DEFAULT_BUS: Optional[EventBus] =
None
227_DEFAULT_BUS_LOCK = threading.Lock()
231 """Return the lazily-constructed process-wide default bus.
233 Callers wanting isolation (tests, sub-agent scopes) should construct their
234 own ``EventBus()`` and never touch this singleton.
237 if _DEFAULT_BUS
is None:
238 with _DEFAULT_BUS_LOCK:
239 if _DEFAULT_BUS
is None:
245 """Clear the default-bus singleton. Test-only — production code never calls this."""
247 with _DEFAULT_BUS_LOCK:
None _dispatch_one(Subscription sub, TurnEvent event)
Subscription replace_subscription(self, Subscription|None old, SubscriptionKey kind, Callback callback)
None publish(self, TurnEvent event)
Subscription subscribe(self, SubscriptionKey kind, Callback callback)
None unsubscribe(self, Subscription subscription)
None _reset_default_bus_for_tests()