Skip to main content

ocserve_core/
event.rs

1//! Event bus: byte- AND count-bounded ring with cursor receivers.
2//!
3//! Contract (captured live, PLAN §3/§4):
4//! - frames: {directory, project, payload:{id,type,properties}}
5//! - durable types (session.updated, message.updated, message.part.updated)
6//!   also get a sync twin {payload:{type:"sync", syncEvent:{id,type:"<t>.1",
7//!   seq, aggregateID, data}}}; seq is per-session monotonic
8//! - overflow policy: slow subscribers are disconnected (bounded by
9//!   construction — no unbounded queues, AGENTS §2.3); clients recover via
10//!   REST snapshot on reconnect (PLAN §4 divergence note)
11//!
12//! Why not `tokio::sync::broadcast` (the obvious choice): broadcast is
13//! **count**-bounded only. Every `message.part.updated` carries the full part
14//! (text included — oc-remote's EventReducer stores these snapshots as the
15//! rendered chat, so event text cannot be truncated: audit 2026-10-02), and
16//! durable emits publish TWO frames (plain + sync twin). A ring of
17//! 1024 × multi-MB frames is a GB-class worst case — the exact unbounded-
18//! growth class that OOMs upstream opencode (its per-subscriber
19//! `Queue.unbounded` + `offerUnsafe`, event.ts:25). This ring is bounded by
20//! **bytes as well as count**, stores each frame serialized ONCE (Arc<str>,
21//! instead of a Value clone per receiver), and never blocks the publisher:
22//! publish = lock + push + evict + watch notify.
23//!
24//! Per-connection memory stays O(1 frame in flight): the SSE task holds at
25//! most one frame while writing to a stalled TCP socket; when the receiver
26//! falls behind the ring, it sees `Lagged` and the stream disconnects
27//! (existing captured behavior — clients reconnect and REST-snapshot).
28
29use serde_json::{Value, json};
30use std::collections::VecDeque;
31use std::sync::atomic::{AtomicUsize, Ordering};
32use std::sync::{Arc, Mutex};
33
34/// Count bound of the shared ring (was the broadcast cap; kept).
35const BUS_CAPACITY: usize = 1024;
36/// Byte bound of the shared ring (new). Sized against MEMORY §1's
37/// unallocated headroom (~146 MB): ring ≤32 MB + transient per-connection
38/// write copies stay inside it. A SINGLE frame larger than the budget is
39/// still admitted (content is contract-critical; the ring sheds everything
40/// else instead) — worst case = max(BUDGET, largest frame).
41pub const RING_BYTE_BUDGET: usize = 32 * 1024 * 1024;
42
43/// Effective byte budget: `OCSERVE_EVENT_RING_MB` (env, SRE tunability) with
44/// the documented default above. Read once (poison-free pattern like the
45/// stall watchdog).
46fn ring_byte_budget() -> usize {
47    static BUDGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
48    *BUDGET.get_or_init(|| {
49        std::env::var("OCSERVE_EVENT_RING_MB")
50            .ok()
51            .and_then(|v| v.parse::<usize>().ok())
52            .filter(|mb| *mb >= 1)
53            .map(|mb| mb * 1024 * 1024)
54            .unwrap_or(RING_BYTE_BUDGET)
55    })
56}
57
58struct RingState {
59    /// (seq, serialized frame)
60    items: VecDeque<(u64, Arc<str>)>,
61    /// last issued seq (0 = none yet)
62    next_seq: u64,
63    total_bytes: usize,
64}
65
66pub struct EventBusInner {
67    ring: Mutex<RingState>,
68    /// watch channel carrying the latest seq (one slot — never grows)
69    head: tokio::sync::watch::Sender<u64>,
70    receivers: AtomicUsize,
71}
72
73#[derive(Clone)]
74pub struct EventBus {
75    inner: Arc<EventBusInner>,
76}
77
78/// Why `recv()` ended: the receiver fell behind (ring evicted its next
79/// frame) or the bus was dropped. Mirrors `broadcast::error::RecvError`
80/// so SSE code treats lag the same way it did before (disconnect).
81#[derive(Debug, PartialEq, Eq)]
82pub enum RecvError {
83    Lagged(u64),
84    Closed,
85}
86
87#[derive(Debug, PartialEq, Eq)]
88pub enum TryRecvError {
89    Empty,
90    Lagged(u64),
91    Closed,
92}
93
94/// Cursor receiver: starts at the head AT SUBSCRIBE TIME (future events
95/// only — same semantics as `broadcast::subscribe`). Not `Clone`.
96pub struct EventBusReceiver {
97    inner: Arc<EventBusInner>,
98    last_seq: u64,
99    /// already-delivered items spilling out of one ring drain
100    pending: VecDeque<String>,
101    head_rx: tokio::sync::watch::Receiver<u64>,
102}
103
104impl EventBus {
105    pub fn new() -> Self {
106        let (head, _) = tokio::sync::watch::channel(0u64);
107        Self {
108            inner: Arc::new(EventBusInner {
109                ring: Mutex::new(RingState {
110                    items: VecDeque::new(),
111                    next_seq: 0,
112                    total_bytes: 0,
113                }),
114                head,
115                receivers: AtomicUsize::new(0),
116            }),
117        }
118    }
119
120    /// Live subscriber count (SSE client gauge, SRE §2).
121    pub fn subscriber_count(&self) -> usize {
122        self.inner.receivers.load(Ordering::Relaxed)
123    }
124
125    /// Publish a fully-encoded global-event frame (payload already wrapped).
126    /// Never blocks on subscribers: evicts OLDEST frames when the ring
127    /// exceeds count or byte bounds (lagging receivers get `Lagged` and
128    /// disconnect — backpressure → disconnect, not stall, not growth).
129    pub fn publish(&self, frame: Value) {
130        self.publish_str(frame.to_string());
131    }
132
133    /// Publish an already-serialized frame (perf dive 2026-10-11): the
134    /// prompt path serializes the same props three times per event (persist,
135    /// plain frame, sync twin) and the two SSE formats re-parse + re-encode
136    /// per subscriber. Keeping the ring items as strings lets every reader
137    /// derive its wire format without a Value round-trip.
138    pub fn publish_str(&self, bytes: String) {
139        let len = bytes.len();
140        let mut ring = self.inner.ring.lock().expect("event ring poisoned");
141        ring.next_seq += 1;
142        let seq = ring.next_seq;
143        ring.total_bytes += len;
144        ring.items.push_back((seq, Arc::from(bytes)));
145        // evict oldest until BOTH bounds hold — but never the sole item
146        // (an oversize frame must still reach clients; content is contract)
147        while ring.items.len() > 1
148            && (ring.items.len() > BUS_CAPACITY || ring.total_bytes > ring_byte_budget())
149        {
150            let (_, evicted) = ring.items.pop_front().expect("non-empty");
151            ring.total_bytes = ring.total_bytes.saturating_sub(evicted.len());
152            ocserve_metrics::counter("ocserve_event_ring_evicted_total", 1);
153        }
154        let depth = ring.items.len() as i64;
155        let bytes_now = ring.total_bytes as i64;
156        drop(ring);
157        ocserve_metrics::gauge("ocserve_event_ring_depth", depth);
158        ocserve_metrics::gauge("ocserve_event_ring_bytes", bytes_now);
159        let _ = self.inner.head.send(seq);
160    }
161
162    /// New receiver for events published AFTER this call.
163    pub fn subscribe(&self) -> EventBusReceiver {
164        let last_seq = self
165            .inner
166            .ring
167            .lock()
168            .expect("event ring poisoned")
169            .next_seq;
170        let head_rx = self.inner.head.subscribe();
171        self.inner.receivers.fetch_add(1, Ordering::Relaxed);
172        EventBusReceiver {
173            inner: self.inner.clone(),
174            last_seq,
175            pending: VecDeque::new(),
176            head_rx,
177        }
178    }
179}
180
181impl Default for EventBus {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl EventBusReceiver {
188    /// Move ring items into `pending` (or report lag). Returns false when
189    /// caught up.
190    fn drain(&mut self) -> Result<bool, RecvError> {
191        let (front, next) = {
192            let ring = self.inner.ring.lock().expect("event ring poisoned");
193            (
194                ring.items.front().map(|(s, _)| *s),
195                ring.items
196                    .iter()
197                    .find(|(s, _)| *s > self.last_seq)
198                    .map(|(s, a)| (*s, a.clone())),
199            )
200        };
201        // gap between my cursor and the oldest resident frame = I lagged
202        if let Some(front) = front
203            && front > self.last_seq + 1
204        {
205            return Err(RecvError::Lagged(front - self.last_seq - 1));
206        }
207        match next {
208            Some((s, frame)) => {
209                self.last_seq = s;
210                // deliver-one-at-a-time like broadcast: exactly one frame per
211                // drain so heartbeat fairness is unchanged
212                self.pending.push_back(frame.to_string());
213                Ok(true)
214            }
215            None => Ok(false),
216        }
217    }
218
219    pub async fn recv(&mut self) -> Result<String, RecvError> {
220        loop {
221            if let Some(f) = self.pending.pop_front() {
222                return Ok(f);
223            }
224            match self.drain() {
225                Ok(true) => continue, // got the next frame into pending
226                Err(e) => return Err(e),
227                Ok(false) => {}
228            }
229            // `changed()` MARKS the value seen when it returns — never call
230            // has_changed() in this loop (it doesn't mark → busy spin).
231            // A publish between drain-empty and changed() returns instantly.
232            if self.head_rx.changed().await.is_err() {
233                if let Ok(true) = self.drain() {
234                    continue;
235                }
236                if let Some(f) = self.pending.pop_front() {
237                    return Ok(f);
238                }
239                return Err(RecvError::Closed);
240            }
241        }
242    }
243
244    pub fn try_recv(&mut self) -> Result<String, TryRecvError> {
245        if let Some(f) = self.pending.pop_front() {
246            return Ok(f);
247        }
248        match self.drain() {
249            Ok(true) => Ok(self.pending.pop_front().expect("drained one")),
250            // Closed is unreachable here: the receiver holds an Arc to the
251            // inner (sender) for its own lifetime — same practical reality
252            // as broadcast with the SSE keepalive holding the bus.
253            Ok(false) => Err(TryRecvError::Empty),
254            Err(RecvError::Lagged(n)) => Err(TryRecvError::Lagged(n)),
255            Err(RecvError::Closed) => Err(TryRecvError::Closed),
256        }
257    }
258}
259
260impl Drop for EventBusReceiver {
261    fn drop(&mut self) {
262        self.inner.receivers.fetch_sub(1, Ordering::Relaxed);
263    }
264}
265
266/// Encode a plain domain event frame.
267pub fn frame(directory: &str, event_type: &str, properties: Value) -> Value {
268    json!({
269        "directory": directory,
270        "project": "global",
271        "payload": {
272            "id": crate::ids::evt_id(),
273            "type": event_type,
274            "properties": normalize_props(event_type, properties)
275        }
276    })
277}
278
279/// Normalize event `properties` to satisfy the frozen contract's required
280/// fields for the event type — a single choke point so no emitter can ship a
281/// spec-violating payload (the 2026-10-09 TUI crash class: `part.updated`
282/// omitted the required `time`). Idempotent: existing values win.
283///
284/// Only fields the spec marks required but that ocserve does not otherwise
285/// carry are injected here; everything else is the emitter's responsibility
286/// (and is checked by `bench/events/event-validate.py`, guard rule 19).
287pub fn normalize_props(event_type: &str, mut props: Value) -> Value {
288    let base = event_type.strip_suffix(".1").unwrap_or(event_type);
289    // v1 `message.part.updated` requires {sessionID, part, time}.
290    if base == "message.part.updated"
291        && props.get("time").is_none()
292        && let Value::Object(m) = &mut props
293    {
294        let now = std::time::SystemTime::now()
295            .duration_since(std::time::UNIX_EPOCH)
296            .map(|d| d.as_millis() as i64)
297            .unwrap_or(0);
298        m.insert("time".into(), json!(now));
299    }
300    props
301}
302
303/// Encode a sync-twin frame (EventReducer consumption): the payload is a
304/// `sync` envelope wrapping the original event with a per-session seq.
305pub fn sync_frame(
306    directory: &str,
307    event_type: &str,
308    properties: Value,
309    seq: u64,
310    session_id: &str,
311) -> Value {
312    json!({
313        "directory": directory,
314        "project": "global",
315        "payload": {
316            "type": "sync",
317            "syncEvent": {
318                "id": crate::ids::evt_id(),
319                "type": format!("{event_type}.1"),
320                "seq": seq,
321                "aggregateID": session_id,
322                "data": properties
323            }
324        }
325    })
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::time::Duration;
332
333    #[test]
334    fn frame_shape_matches_capture() {
335        let f = frame("/x", "session.idle", json!({"sessionID": "ses_1"}));
336        assert_eq!(f["directory"], "/x");
337        assert_eq!(f["project"], "global");
338        assert_eq!(f["payload"]["type"], "session.idle");
339        assert_eq!(f["payload"]["properties"]["sessionID"], "ses_1");
340        assert!(f["payload"]["id"].as_str().unwrap().starts_with("evt_"));
341    }
342
343    /// normalize_props injects the required `time` on message.part.updated
344    /// (spec Event.message.part.updated requires {sessionID, part, time});
345    /// ocserve omitted it → the 2026-10-09 TUI crash class. Idempotent.
346    #[test]
347    fn normalize_injects_part_time() {
348        let p = normalize_props(
349            "message.part.updated",
350            json!({"sessionID": "ses_1", "part": {"id": "prt_1"}}),
351        );
352        assert!(p["time"].is_number(), "time injected: {p}");
353        // idempotent: an existing time wins
354        let p2 = normalize_props(
355            "message.part.updated",
356            json!({"sessionID": "ses_1", "part": {}, "time": 7}),
357        );
358        assert_eq!(p2["time"], 7);
359        // sync twin suffix resolves to the same base type
360        let p3 = normalize_props("message.part.updated.1", json!({"part": {}}));
361        assert!(p3["time"].is_number());
362        // other types untouched
363        let p4 = normalize_props("session.idle", json!({"sessionID": "ses_1"}));
364        assert!(p4.get("time").is_none());
365    }
366
367    #[test]
368    fn sync_frame_shape_matches_capture() {
369        let f = sync_frame(
370            "/x",
371            "message.updated",
372            json!({"sessionID": "ses_1"}),
373            7,
374            "ses_1",
375        );
376        let se = &f["payload"]["syncEvent"];
377        assert_eq!(se["type"], "message.updated.1");
378        assert_eq!(se["seq"], 7);
379        assert_eq!(se["aggregateID"], "ses_1");
380        assert_eq!(se["data"]["sessionID"], "ses_1");
381    }
382
383    #[tokio::test]
384    async fn bus_delivers_and_bounds_by_count() {
385        let bus = EventBus::new();
386        let mut rx = bus.subscribe();
387        bus.publish(frame("/x", "session.idle", json!({})));
388        let got = rx.recv().await.unwrap();
389        let v: Value = serde_json::from_str(&got).unwrap();
390        assert_eq!(v["payload"]["type"], "session.idle");
391        // overflow → Lagged error (not a stall); receiver side disconnects
392        for _ in 0..2000 {
393            bus.publish(frame("/x", "session.status", json!({})));
394        }
395        match rx.recv().await {
396            Ok(_) | Err(RecvError::Lagged(_)) => {}
397            e => panic!("unexpected bus result: {e:?}"),
398        }
399    }
400
401    /// NEGATIVE-CONTROL pair for the byte bound: publishing frames that
402    /// would blow the count bound's byte budget must evict (depth AND bytes
403    /// stay ≤ budget) — remove the eviction condition and this fails.
404    #[tokio::test]
405    async fn ring_is_byte_bounded_not_just_count_bounded() {
406        let bus = EventBus::new();
407        // one 16 MB frame ×4 = 64 MB > 32 MB budget (count stays under1024)
408        let big = "x".repeat(16 * 1024 * 1024);
409        for i in 0..4 {
410            bus.publish(frame(
411                "/x",
412                "message.part.updated",
413                json!({"i": i, "big": big}),
414            ));
415        }
416        let ring = bus.inner.ring.lock().unwrap();
417        assert!(
418            ring.total_bytes <= RING_BYTE_BUDGET,
419            "ring bytes {} exceeded budget",
420            ring.total_bytes
421        );
422        assert!(
423            ring.items.len() < 4,
424            "oversize frames must evict: depth {}",
425            ring.items.len()
426        );
427        // newest frame survives
428        let last = ring.items.back().unwrap();
429        assert_eq!(last.0, ring.next_seq, "newest frame kept");
430    }
431
432    /// An oversize frame (larger than the whole budget) is still admitted
433    /// (content contract) and evicts everything else — bounded BY it.
434    #[tokio::test]
435    async fn single_oversize_frame_admitted_alone() {
436        let bus = EventBus::new();
437        for i in 0..5 {
438            bus.publish(frame("/x", "t", json!({"i": i})));
439        }
440        let huge = "y".repeat(RING_BYTE_BUDGET + 1024);
441        bus.publish(frame("/x", "message.part.updated", json!({"big": huge})));
442        let ring = bus.inner.ring.lock().unwrap();
443        assert_eq!(
444            ring.items.len(),
445            1,
446            "everything else shed for the oversize frame"
447        );
448        assert!(ring.total_bytes > RING_BYTE_BUDGET);
449    }
450
451    /// The publisher never blocks on slow subscribers: no receiver drains
452    /// anything and publishes still complete promptly (the upstream OOM
453    /// pattern is offering into unbounded per-subscriber queues instead).
454    #[tokio::test]
455    async fn publisher_never_blocks_on_stalled_receivers() {
456        let bus = EventBus::new();
457        let _slow: Vec<_> = (0..8).map(|_| bus.subscribe()).collect();
458        let t0 = std::time::Instant::now();
459        for _ in 0..5000 {
460            bus.publish(frame(
461                "/x",
462                "message.part.updated",
463                json!({"pad": "z".repeat(1024)}),
464            ));
465        }
466        assert!(
467            t0.elapsed() < Duration::from_secs(5),
468            "publish loop took {:?}",
469            t0.elapsed()
470        );
471        // stalled receivers are lagging, not growing: ring stays bounded
472        let ring = bus.inner.ring.lock().unwrap();
473        assert!(ring.items.len() <= BUS_CAPACITY);
474        assert!(ring.total_bytes <= RING_BYTE_BUDGET);
475    }
476
477    #[tokio::test]
478    async fn subscribe_only_sees_future_and_try_recv_roundtrips() {
479        let bus = EventBus::new();
480        bus.publish(frame("/x", "old", json!({})));
481        let mut rx = bus.subscribe();
482        assert!(
483            matches!(rx.try_recv(), Err(TryRecvError::Empty)),
484            "subscribe is future-only (broadcast parity)"
485        );
486        bus.publish(frame("/x", "new", json!({})));
487        let got = rx.try_recv().unwrap();
488        assert!(got.contains("\"type\": \"new\"") || got.contains("\"type\":\"new\""));
489        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
490        assert_eq!(bus.subscriber_count(), 1);
491        drop(rx);
492        assert_eq!(bus.subscriber_count(), 0);
493    }
494}
495
496#[cfg(test)]
497mod unfold_tests {
498    use super::*;
499    use std::time::Duration;
500
501    /// Replicates ocserve-http's SSE merge loop under paused time.
502    #[tokio::test(start_paused = true)]
503    async fn heartbeat_emits_at_10s_and_stream_stays_open() {
504        let bus = EventBus::new();
505        let mut rx = bus.subscribe();
506        let mut next_hb = tokio::time::Instant::now() + Duration::from_secs(10);
507        let mut got_hb = false;
508        for _ in 0..3 {
509            let dur = next_hb.saturating_duration_since(tokio::time::Instant::now());
510            match tokio::time::timeout(dur.max(Duration::from_millis(1)), rx.recv()).await {
511                Ok(Ok(_frame)) => panic!("unexpected frame"),
512                Ok(Err(e)) => panic!("recv errored: {e:?}"),
513                Err(_elapsed) => {
514                    got_hb = true;
515                    next_hb += Duration::from_secs(10);
516                }
517            }
518        }
519        assert!(got_hb, "heartbeat fired");
520        // frames still flow after the heartbeats
521        bus.publish(frame("/x", "after", json!({})));
522        let got = tokio::time::timeout(Duration::from_secs(1), rx.recv())
523            .await
524            .expect("frame arrives")
525            .expect("not lagged");
526        assert!(got.contains("after"));
527    }
528}