//! T M10.6 — Per-tick presence broadcast. //! //! # Contract //! //! `SessionRegistry` is the daemon-side bookkeeping for the //! `InstanceMessage::PresenceUpdate` flow: //! //! - Per-session **negotiated protocol version** so the per-tick //! sweep can filter v0.1 recipients (presence didn't exist on the //! v0.1 wire — the variant lives in the v1.0+ enum and v1 sessions //! must never receive it). //! - Per-source **last-broadcast snapshot** so the sweep can //! short-circuit when nothing changed since the last tick. This is //! the coalescing implementation: rapid cursor movement between //! sweeps produces one broadcast carrying the *final* state, not //! N broadcasts carrying intermediate values. //! //! # Equality discipline //! //! `PresenceSnapshot`'s `PartialEq` is exactly the wire-representation //! equality: two snapshots compare equal iff the //! `InstanceMessage::PresenceUpdate`s built from them would serialize //! to identical bytes. The flat shape ([`Position`] = u64; //! `Option` is a flat pair of u64s) makes this //! property structural — no internal state can affect equality //! without also changing the wire bytes. //! //! If a future field is added to `PresenceSnapshot` or //! `SelectionSnapshot` that does NOT affect the wire (e.g., a //! daemon-internal annotation), the derive(PartialEq) needs //! revisiting — otherwise the sweep emits spurious broadcasts on //! changes the wire would not encode. //! //! # M10.6 single-frontend behavior //! //! The daemon is single-frontend in M10.6 — only one session is //! registered at a time, and the sweep's sender-exclusion + v2- //! recipient filter produces an empty broadcast list. The call site //! exists; the data flow is wired; the recipient list is structurally //! empty until M10.8 enables multi-attach. //! //! # M10.7 / M10.8 forward-pointers //! //! - M10.7 tightens recipient filtering to also require //! `crdt_replica`/`multi_frontend` capability bits, not just v2 //! protocol. The current sweep takes only the negotiated version; //! M10.7 will extend the session-state type to carry capabilities //! and the sweep will consult both. //! - M10.8 wires the multi-frontend session dispatcher. The tracker's //! `register_session` / `unregister_session` API will be called per //! attach/detach; the sweep's broadcast list becomes non-empty. use std::collections::HashMap; use crate::buffer::BufferId; use crate::protocol::{FrontendId, InstanceMessage, NegotiatedCapabilities, SelectionSnapshot}; use crate::rope::Position; /// T M10.7 — per-session daemon-internal state. /// /// One entry per attached session, keyed by `FrontendId` in the /// tracker's `sessions` map. M10.7 ships with two fields; future /// milestones append fields with sensible defaults — e.g., M10.8 may /// add per-session view state, M11 may add per-session keymap /// overlays. Append-only growth keeps existing call sites valid. /// /// **Module-location note**: `SessionState` lives in `presence.rs` /// today because that's where M10.6 introduced the per-session /// tracking. M10.8 will likely want this type accessible from a /// session-routing module as well; relocating to a neutral location /// (e.g., `src/session.rs`) is M10.8's call. M10.7 leaves it here /// with this note. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub struct SessionState { /// The protocol version negotiated during the handshake. Always /// a member of `SUPPORTED_PROTOCOL_VERSIONS` (the daemon checks /// before constructing this). v0.1 frontends produce `1`; v1.0+ /// frontends produce `2`. pub negotiated_protocol_version: u32, /// The capability bits negotiated during the handshake. v0.1 /// frontends always end up with all bits `false` (their wire /// format does not carry capability fields; `#[serde(default)]` /// produces `false` on the daemon side). pub negotiated_capabilities: NegotiatedCapabilities, /// T M10.9 — color palette slot for this session's overlay /// rendering. Daemon assigns at attach time based on the /// connecting peer's Unix uid (`SO_PEERCRED`); same uid across /// reconnect → same slot. Slot resolves to a `Color` via /// [`crate::overlay_color::color_for_slot`]. pub color_slot: u8, } impl SessionState { /// Convenience constructor used by tests and the daemon's /// handshake path. Takes the version, capabilities, and color /// slot. #[must_use] pub fn new( negotiated_protocol_version: u32, negotiated_capabilities: NegotiatedCapabilities, color_slot: u8, ) -> Self { Self { negotiated_protocol_version, negotiated_capabilities, color_slot, } } } /// One source frontend's presence at a tick boundary. /// /// Equality is wire-equality — two snapshots compare equal iff they /// would serialize to identical [`InstanceMessage::PresenceUpdate`] /// bytes. See module docs for the discipline this implies. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub struct PresenceSnapshot { /// Buffer the source frontend's cursor is in. pub buffer_id: BufferId, /// Byte offset of the source frontend's cursor within `buffer_id`. pub cursor: Position, /// Active selection range, if any. pub selection: Option, } /// One outbound presence broadcast: which session receives which message. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BroadcastEntry { /// The session receiving the message. Sender exclusion is the /// tracker's responsibility — the recipient is never the source. pub recipient: FrontendId, /// The wire message to send. Always /// [`InstanceMessage::PresenceUpdate`] in M10.6. pub message: InstanceMessage, } /// Per-tick presence diff + broadcast routing. /// /// One instance lives on the daemon's per-attach path. M10.6's /// single-frontend deployment means at most one session is registered /// at a time; M10.8 generalizes to multiple sessions. #[derive(Debug, Default)] pub struct SessionRegistry { /// Per-source last-broadcast snapshot. A source is present here /// iff at least one sweep has emitted (or considered emitting) a /// broadcast for it. `None` (absent) means "no prior state" — /// the first sweep observes a change. last_broadcast: HashMap, /// Per-session daemon-internal state — negotiated protocol /// version + negotiated capability bits. M10.7 widened this from /// a bare `u32` version to the richer `SessionState` once /// capability negotiation became load-bearing. Recipients are /// filtered on `negotiated_capabilities.multi_frontend` (M10.7); /// v0.1 sessions naturally fail the filter because their /// declared bit defaults to `false` and the AND with any /// instance bit is `false`. sessions: HashMap, } impl SessionRegistry { /// Fresh tracker with no sessions and no prior broadcasts. #[must_use] pub fn new() -> Self { Self::default() } /// Register a session with its negotiated state (T M10.7). /// /// Called on attach after both the version-check predicate /// (`is_supported_protocol_version`) and the capability /// negotiation (`negotiate_capabilities`) have accepted the /// request. The `SessionState` carries the negotiated version /// AND the negotiated capability bits — M10.7 widened this from /// the M10.6 bare-`u32` shape. pub fn register_session(&mut self, frontend_id: FrontendId, state: SessionState) { self.sessions.insert(frontend_id, state); } /// Unregister a session on detach. Drops both the session entry /// and any last-broadcast state for that frontend so a future /// re-attach starts with no prior state. pub fn unregister_session(&mut self, frontend_id: FrontendId) { self.sessions.remove(&frontend_id); self.last_broadcast.remove(&frontend_id); } /// The negotiated state for `frontend_id`, or `None` if no /// session is registered for it. #[must_use] pub fn session_state(&self, frontend_id: FrontendId) -> Option { self.sessions.get(&frontend_id).copied() } /// Number of registered sessions. Useful for tests + diagnostics. #[must_use] pub fn session_count(&self) -> usize { self.sessions.len() } /// T M10.9 — gather `OtherPresence` entries for the overlay /// renderer to paint into `recipient`'s grid. /// /// Returns `(source, snapshot, color_slot)` triples for every /// registered session whose: /// - `source != recipient` (sender exclusion) /// - `negotiated_capabilities.multi_frontend` is true (same /// filter as `sweep`; v0.1 sessions don't broadcast presence) /// - has a `last_broadcast` entry (i.e., has produced a snapshot /// the sweep observed) /// /// The recipient itself doesn't need a `multi_frontend` /// capability check — the caller only invokes this for /// recipients that will RECEIVE overlays, which by definition /// means they're multi-capable. #[must_use] pub fn other_presences_for( &self, recipient: FrontendId, ) -> Vec { let mut out = Vec::new(); for (&source, state) in &self.sessions { if source == recipient { continue; } if !state.negotiated_capabilities.multi_frontend { continue; } if let Some(&snapshot) = self.last_broadcast.get(&source) { out.push(crate::overlay_paint::OtherPresence { frontend_id: source, snapshot, color_slot: state.color_slot, }); } } out } /// Sweep: given per-source current snapshots, return the /// broadcasts to send this tick. /// /// For each `(source, snapshot)`: /// 1. If `snapshot == last_broadcast[source]`, no change → no /// broadcast for this source. /// 2. Otherwise update `last_broadcast[source]` and emit one /// [`BroadcastEntry`] per recipient where recipient is /// registered, recipient != source (sender exclusion), and /// the recipient's negotiated version is `>= 2` (v0.1 filter). /// /// Coalescing is structural: the sweep is called once per tick; /// multiple cursor movements between sweeps are observed as one /// snapshot (the final state). The N-moves-coalesce-to-1 /// property follows from the sweep cadence, not from any /// timestamp / counter inside the snapshot. /// /// In M10.6 single-frontend deployments: the recipient list is /// structurally empty (sender exclusion with no other sessions), /// so the returned vec is always empty even if the snapshot /// changed. The construct-then-fan-out shape is preserved so /// M10.8 doesn't restructure the code; only the recipient set /// grows. pub fn sweep(&mut self, current: &[(FrontendId, PresenceSnapshot)]) -> Vec { let mut out = Vec::new(); for (source, snapshot) in current { let changed = self .last_broadcast .get(source) .is_none_or(|prev| prev != snapshot); if !changed { continue; } self.last_broadcast.insert(*source, *snapshot); // Build the wire message once per source; the recipient // list may be empty (M10.6 single-frontend), in which // case the message is constructed but never serialized. // M10.8 enables non-empty recipient lists. let message = InstanceMessage::PresenceUpdate { frontend_id: *source, buffer_id: snapshot.buffer_id, cursor: snapshot.cursor, selection: snapshot.selection, }; for (&recipient, state) in &self.sessions { if recipient == *source { continue; } // T M10.7: filter on the negotiated capability bit. // M10.6 filtered on `version >= 2`; M10.7 tightens to // `multi_frontend = true`. v0.1 sessions naturally // fail the filter (their declared bit defaults to // false → AND with instance is false). v1.0 sessions // that declined `multi_frontend` during negotiation // also fail — they opted into single-frontend mode. if !state.negotiated_capabilities.multi_frontend { continue; } out.push(BroadcastEntry { recipient, message: message.clone(), }); } } out } /// T M10.8 Day 4 — broadcast one `InstanceMessage::CrdtOp` to /// every registered session that negotiated `crdt_replica: true`, /// excluding the source. /// /// Cadence differs from [`Self::sweep`]: presence sweeps run /// once per tick; CRDT-op broadcasts run once per edit event /// that produced a `Edit::crdt_op` payload. Keeping them as /// separate methods reflects the cadence difference and avoids /// awkward "one of these arguments is the presence, the other /// is the CRDT op" signature overloads. /// /// Sender exclusion: when `exclude` is `Some(fid)`, that frontend /// is filtered out (it already applied the op via its local /// mirror — see M10.10 post-audit-round-3 F16 / `CrdtOpOrigin`). /// When `exclude` is `None`, every `crdt_replica`-capable /// recipient receives the op including any frontend that may /// have driven the daemon's mutation via `FrontendEvent::Key` /// (whose mirror is otherwise stale). Recipient filter: /// `negotiated_capabilities.crdt_replica == true`. pub fn broadcast_crdt_op( &self, exclude: Option, buffer_id: BufferId, op: crate::rope::CrdtOp, ) -> Vec { let mut out = Vec::new(); let message = InstanceMessage::CrdtOp { buffer_id, op }; for (&recipient, state) in &self.sessions { if Some(recipient) == exclude { continue; } if !state.negotiated_capabilities.crdt_replica { continue; } out.push(BroadcastEntry { recipient, message: message.clone(), }); } out } } #[cfg(test)] mod tests { use super::*; fn snap(buffer_id: BufferId, cursor: Position) -> PresenceSnapshot { PresenceSnapshot { buffer_id, cursor, selection: None, } } /// Session state for a v2 frontend that negotiated multi-frontend /// capability — the "presence-eligible recipient" case. /// Color slot 0 is the test default (palette[0]). fn multi_session() -> SessionState { SessionState::new( 2, NegotiatedCapabilities { multi_frontend: true, crdt_replica: false, semantic_render: false, }, 0, ) } /// Session state for a v0.1 frontend — `multi_frontend` defaults /// to false (the v0.1 wire format doesn't carry the field; /// `#[serde(default)]` produces false on the daemon side). This /// is the "presence filtered out" recipient case. fn legacy_session() -> SessionState { SessionState::new( 1, NegotiatedCapabilities { multi_frontend: false, crdt_replica: false, semantic_render: false, }, 0, ) } #[test] fn new_is_empty() { let t = SessionRegistry::new(); assert_eq!(t.session_count(), 0); assert_eq!(t.session_state(FrontendId(2)), None); } #[test] fn register_and_unregister_session() { let mut t = SessionRegistry::new(); t.register_session(FrontendId(2), multi_session()); assert_eq!(t.session_count(), 1); assert_eq!( t.session_state(FrontendId(2)) .map(|s| s.negotiated_protocol_version), Some(2) ); t.unregister_session(FrontendId(2)); assert_eq!(t.session_count(), 0); assert_eq!(t.session_state(FrontendId(2)), None); } #[test] fn sweep_excludes_sender_in_single_frontend() { // T M10.6 acceptance — sender exclusion. A multi-frontend // session sees its own cursor move; sweep produces no // broadcast because the only recipient candidate is the // sender itself. let mut t = SessionRegistry::new(); let fid = FrontendId(2); t.register_session(fid, multi_session()); let buf = BufferId::next(); let out = t.sweep(&[(fid, snap(buf, 10))]); assert!( out.is_empty(), "sender excluded — single-frontend sweep should produce no broadcast, got {out:?}" ); } #[test] fn sweep_excludes_recipient_without_multi_frontend_capability() { // T M10.7 (was M10.6 v1-filter test) — recipients without // negotiated multi_frontend are filtered out. v0.1 sessions // naturally fail because their declared bit defaults to // false; v1.0 sessions that declined multi_frontend during // negotiation also fail (they opted into single-frontend // mode). let mut t = SessionRegistry::new(); let src = FrontendId(2); let legacy_recipient = FrontendId(3); t.register_session(src, multi_session()); t.register_session(legacy_recipient, legacy_session()); let buf = BufferId::next(); let out = t.sweep(&[(src, snap(buf, 10))]); assert!( out.is_empty(), "recipient without multi_frontend filtered out — got {out:?}" ); } #[test] fn sweep_broadcasts_to_multi_frontend_recipient() { // T M10.6/7 acceptance — multi-frontend source + multi- // frontend recipient: one message. This is the M10.8 case; // the tracker handles it correctly even though M10.6/7's // daemon doesn't admit multiple sessions. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient = FrontendId(3); t.register_session(src, multi_session()); t.register_session(recipient, multi_session()); let buf = BufferId::next(); let out = t.sweep(&[(src, snap(buf, 10))]); assert_eq!(out.len(), 1); assert_eq!(out[0].recipient, recipient); match &out[0].message { InstanceMessage::PresenceUpdate { frontend_id, cursor, .. } => { assert_eq!(*frontend_id, src); assert_eq!(*cursor, 10); } other => panic!("expected PresenceUpdate, got {other:?}"), } } #[test] fn sweep_suppresses_when_snapshot_unchanged() { // T M10.6 acceptance — diff suppression. Second sweep with // identical snapshot produces no broadcast even though // recipients exist. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient = FrontendId(3); t.register_session(src, multi_session()); t.register_session(recipient, multi_session()); let buf = BufferId::next(); let s = snap(buf, 10); let out1 = t.sweep(&[(src, s)]); assert_eq!(out1.len(), 1, "first sweep broadcasts"); let out2 = t.sweep(&[(src, s)]); assert!( out2.is_empty(), "second sweep with unchanged snapshot is suppressed, got {out2:?}" ); } #[test] fn sweep_coalesces_intermediate_moves_to_final_state() { // T M10.6 acceptance — coalescing. The sweep is called once // per tick, observing the snapshot at the moment of the // sweep. Multiple cursor moves between sweeps appear to the // tracker as one snapshot change (from prev to final). The // coalescing-to-1 property is structural: the daemon calls // sweep once per tick, regardless of how many cursor moves // happened during the tick. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient = FrontendId(3); t.register_session(src, multi_session()); t.register_session(recipient, multi_session()); let buf = BufferId::next(); // Tick 1: snapshot at cursor=10. First sweep broadcasts. let out1 = t.sweep(&[(src, snap(buf, 10))]); assert_eq!(out1.len(), 1); // Between ticks 1 and 2: cursor moves 10 → 20 → 30 → 99 // (intermediate moves happen, but no sweep). Tick 2 observes // the final snapshot (cursor=99) only. let out2 = t.sweep(&[(src, snap(buf, 99))]); assert_eq!(out2.len(), 1, "tick 2 broadcasts once"); match &out2[0].message { InstanceMessage::PresenceUpdate { cursor, .. } => { assert_eq!( *cursor, 99, "broadcast carries final snapshot value (99), not any intermediate (20, 30, …)" ); } other => panic!("expected PresenceUpdate, got {other:?}"), } } #[test] fn sweep_first_tick_is_change() { // T M10.6 — the first tick after a session registers has no // prior state in last_broadcast. The diff treats absent-prior // as "changed" so the initial cursor position is broadcast // to any registered multi-frontend recipients. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient = FrontendId(3); t.register_session(src, multi_session()); t.register_session(recipient, multi_session()); let buf = BufferId::next(); let out = t.sweep(&[(src, snap(buf, 0))]); assert_eq!(out.len(), 1, "first sweep broadcasts initial state"); } #[test] fn unregister_clears_last_broadcast() { // T M10.6 — re-attaching after unregister starts fresh. The // last_broadcast entry is dropped on unregister so the next // sweep after re-register sees absent-prior and broadcasts. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient = FrontendId(3); t.register_session(src, multi_session()); t.register_session(recipient, multi_session()); let buf = BufferId::next(); // First attach: broadcast initial state. let out1 = t.sweep(&[(src, snap(buf, 10))]); assert_eq!(out1.len(), 1); // Detach + re-attach: state cleared. t.unregister_session(src); t.register_session(src, multi_session()); // First sweep after re-attach: still cursor=10, but the // last_broadcast was cleared on unregister, so the diff says // "changed" and we broadcast. let out2 = t.sweep(&[(src, snap(buf, 10))]); assert_eq!( out2.len(), 1, "re-attach with same cursor still broadcasts (last_broadcast was cleared)" ); } #[test] fn sweep_handles_selection_diff() { // T M10.6 — selection change alone (cursor unchanged) is a // diff and triggers broadcast. Equality is wire-equality, so // selection: None vs Some(anchor=cursor=10) compare unequal. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient = FrontendId(3); t.register_session(src, multi_session()); t.register_session(recipient, multi_session()); let buf = BufferId::next(); let without_sel = PresenceSnapshot { buffer_id: buf, cursor: 10, selection: None, }; let with_sel = PresenceSnapshot { buffer_id: buf, cursor: 10, selection: Some(SelectionSnapshot { anchor: 5, active: 10, }), }; let _ = t.sweep(&[(src, without_sel)]); let out = t.sweep(&[(src, with_sel)]); assert_eq!(out.len(), 1, "selection-only change broadcasts"); } #[test] fn sweep_multiple_sources_produce_independent_broadcasts() { // T M10.6 — when M10.8 enables multiple multi-frontend // sources, each source's snapshot diff is independent. // Per-tick sweep emits one broadcast per (source, recipient) // pair where the source's snapshot changed. let mut t = SessionRegistry::new(); let a = FrontendId(2); let b = FrontendId(3); let c = FrontendId(4); t.register_session(a, multi_session()); t.register_session(b, multi_session()); t.register_session(c, multi_session()); let buf = BufferId::next(); // Tick 1: A at 10, B at 20. Both change (no prior). C is a // recipient of both A and B (and a sender to A and B, but // C didn't move so no broadcast originates from C). let out = t.sweep(&[(a, snap(buf, 10)), (b, snap(buf, 20))]); // From A: broadcasts to B and C (2 entries). // From B: broadcasts to A and C (2 entries). // Total: 4 broadcasts. assert_eq!( out.len(), 4, "two sources × two recipients each = 4 entries" ); // Tick 2: A unchanged, B moved to 25. let out2 = t.sweep(&[(a, snap(buf, 10)), (b, snap(buf, 25))]); // From A: unchanged, no broadcast. // From B: changed, broadcasts to A and C. assert_eq!(out2.len(), 2, "only B's change produces broadcasts"); } #[test] fn sweep_with_no_sources_is_noop() { // Defensive — empty current list is the trivial case (no // attached frontends moved this tick). The sweep returns // empty without touching last_broadcast. let mut t = SessionRegistry::new(); t.register_session(FrontendId(2), multi_session()); let out = t.sweep(&[]); assert!(out.is_empty()); } // T M10.8 Day 4 — broadcast_crdt_op filter matrix. /// Session state for a v2 frontend that negotiated `crdt_replica` capability. fn crdt_session() -> SessionState { SessionState::new( 2, NegotiatedCapabilities { multi_frontend: true, crdt_replica: true, semantic_render: false, }, 0, ) } /// Session state for a v2 frontend that opted out of `crdt_replica`. fn no_crdt_session() -> SessionState { SessionState::new( 2, NegotiatedCapabilities { multi_frontend: true, crdt_replica: false, semantic_render: false, }, 0, ) } fn dummy_crdt_op() -> crate::rope::CrdtOp { crate::rope::CrdtOp { peer_id: 7, bytes: vec![0xDE, 0xAD, 0xBE, 0xEF], } } #[test] fn broadcast_crdt_op_excludes_sender() { // M10.8 acceptance criterion 1 (sender exclusion): a source // doesn't receive its own CRDT op back. let mut t = SessionRegistry::new(); let src = FrontendId(2); t.register_session(src, crdt_session()); let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op()); assert!( out.is_empty(), "sender excluded; single-session broadcast empty: {out:?}" ); } #[test] fn broadcast_crdt_op_routes_to_crdt_capable_recipient() { // M10.8 acceptance criterion 3 (capability filter): a // recipient that negotiated crdt_replica receives. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient = FrontendId(3); t.register_session(src, crdt_session()); t.register_session(recipient, crdt_session()); let buf = BufferId::next(); let out = t.broadcast_crdt_op(Some(src), buf, dummy_crdt_op()); assert_eq!(out.len(), 1); assert_eq!(out[0].recipient, recipient); match &out[0].message { InstanceMessage::CrdtOp { buffer_id, op: crate::rope::CrdtOp { peer_id, bytes }, } => { assert_eq!(*buffer_id, buf); assert_eq!(*peer_id, 7); assert_eq!(bytes, &vec![0xDE, 0xAD, 0xBE, 0xEF]); } other => panic!("expected CrdtOp, got {other:?}"), } } #[test] fn broadcast_crdt_op_filters_recipient_without_crdt_replica() { // M10.8 acceptance criterion 3: recipient with // `crdt_replica: false` doesn't receive. let mut t = SessionRegistry::new(); let src = FrontendId(2); let recipient_no_crdt = FrontendId(3); t.register_session(src, crdt_session()); t.register_session(recipient_no_crdt, no_crdt_session()); let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op()); assert!( out.is_empty(), "recipient with crdt_replica=false filtered out: {out:?}" ); } #[test] fn broadcast_crdt_op_filters_legacy_recipient() { // v0.1 sessions have `crdt_replica: false` by default — the // M10.5 wire format doesn't carry the field and // `#[serde(default)]` produces false. They're naturally // filtered out. let mut t = SessionRegistry::new(); let src = FrontendId(2); let legacy_recipient = FrontendId(3); t.register_session(src, crdt_session()); t.register_session(legacy_recipient, legacy_session()); let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op()); assert!(out.is_empty(), "legacy recipient filtered out: {out:?}"); } #[test] fn broadcast_crdt_op_routes_to_multiple_recipients() { // M10.8 multi-attach case: one source, two crdt-capable // recipients → 2 broadcasts. let mut t = SessionRegistry::new(); let src = FrontendId(2); let r1 = FrontendId(3); let r2 = FrontendId(4); t.register_session(src, crdt_session()); t.register_session(r1, crdt_session()); t.register_session(r2, crdt_session()); let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op()); assert_eq!(out.len(), 2); let recipients: std::collections::HashSet<_> = out.iter().map(|e| e.recipient).collect(); assert!(recipients.contains(&r1)); assert!(recipients.contains(&r2)); } #[test] fn broadcast_crdt_op_no_recipients_is_noop() { // Source is the only session; broadcast empty. let mut t = SessionRegistry::new(); let src = FrontendId(2); t.register_session(src, crdt_session()); let out = t.broadcast_crdt_op(Some(src), BufferId::next(), dummy_crdt_op()); assert!(out.is_empty()); } /// M10.10 post-audit-round-3 F16: `exclude = None` broadcasts /// to **all** crdt-capable replicas including the frontend /// whose `Key` event drove the edit (its mirror is stale /// otherwise). #[test] fn broadcast_crdt_op_none_exclude_reaches_all_replicas() { let mut t = SessionRegistry::new(); let a = FrontendId(2); let b = FrontendId(3); t.register_session(a, crdt_session()); t.register_session(b, crdt_session()); let out = t.broadcast_crdt_op(None, BufferId::next(), dummy_crdt_op()); let recipients: std::collections::HashSet<_> = out.iter().map(|e| e.recipient).collect(); assert!( recipients.contains(&a) && recipients.contains(&b), "F16: None-exclude must include the active frontend whose mirror is stale" ); assert_eq!(out.len(), 2); } }