mumble_server_runtime_shard/shard.rs
1//! The shard: one render, one journal, one task.
2//!
3//! ```text
4//! business state changes
5//! | wake()
6//! v
7//! +------------------------------------------------------+
8//! | the shard's task (woken, one per shard) |
9//! | |
10//! | 1. logic.render(&mut builder) |
11//! | -> the SHARED view + the PRIVATE overlays |
12//! | + the AUDIO relation |
13//! | 2. ops = plan(current view -> new view) |
14//! | 3. version += 1, ops -> journal |
15//! | 4. publish the audio routing table |
16//! | 5. for each connection: |
17//! | its observation changed? -> replan for it |
18//! | otherwise -> filter, splice, collapse, encode, |
19//! | push, advance its committed state |
20//! +------------------------------------------------------+
21//! ```
22//!
23//! # The rules this module must never break
24//!
25//! 1. A shard task never awaits IO.
26//! 2. The audio routing table is published **before** the views are pushed. A
27//! revoked route disappears immediately, which is always safe: cutting too
28//! early means hearing less than your due, never more. A granted route is
29//! inert until the receiver's cursor catches up.
30//! 3. The committed state of a connection is a **quadruplet** - cursor,
31//! observation, overlay, offered actions - and the four only advance together,
32//! once the queue has accepted everything. This is what lets a lagging
33//! connection catch up naturally: the shared part replays from the journal,
34//! the private parts are recomputed from what was last accepted, so neither an
35//! overlay nor an action ever needs journalling.
36//! 4. An invalid render never replaces the current view: log it, keep the old
37//! one, and do not close anything.
38//!
39//! REF: docs/design/guide-implementation.md 9
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::sync::Arc;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::time::Duration;
45
46use mumble_server_runtime_protocol::ControlMessage;
47use tokio::sync::mpsc::error::TryRecvError;
48use tokio::sync::{Notify, mpsc, oneshot, watch};
49use tokio::time::Instant;
50
51use crate::build::{BuildError, ShardBuilder};
52use crate::compose::{collapse, filter};
53use crate::emit::{TextTarget, emit};
54use crate::ids::{
55 ActionKey, ChannelId, ChannelKey, ConnectionId, Occupant, SessionId, ShardId, SharedIds,
56};
57use crate::journal::Journal;
58use crate::plan::{PlanOp, plan, plan_elements};
59use crate::queue::{OutboundQueue, Refused};
60use crate::reply::{Audience, Effects, Reply, Spoken};
61use crate::routing::{AudioRelation, AudioRouting, Silence, compile};
62use crate::scope::ScopeSet;
63use crate::view::{Actions, Channel, On, Overlay, ShardView, User};
64
65/// The floor between two publications.
66///
67/// Commands and flavor events are still absorbed immediately while this
68/// cooldown runs. Only reconciliation is rate-limited, so a burst becomes one
69/// publication instead of one 50 ms delay per command.
70pub const MIN_INTERVAL: Duration = Duration::from_millis(50);
71
72/// A voice-plane fact reported to the flavor.
73///
74/// Always a statement about the runtime, never a command: the flavor alone
75/// decides what its state becomes, and ignoring an event is a valid answer.
76#[derive(Debug, Clone, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum VoiceEvent {
79 /// A connection was attached to this shard.
80 Connected { connection: ConnectionId },
81 /// A connection is gone. No further event will carry it.
82 Disconnected {
83 connection: ConnectionId,
84 reason: String,
85 },
86 /// A connection left for another shard. Its socket is still alive and its
87 /// session is unchanged; only this shard stops describing it.
88 ///
89 /// Distinct from [`VoiceEvent::Disconnected`] because the two mean opposite
90 /// things to a flavor: a disconnect frees a slot, a migration hands it over.
91 Migrated {
92 connection: ConnectionId,
93 to: ShardId,
94 },
95 /// The client asked to enter a channel, by double-clicking it or dragging
96 /// itself into it.
97 ///
98 /// A request, never a fact: the channel is one this connection can actually
99 /// see, and nothing has moved. What it means is entirely the flavor's
100 /// business, up to and including ignoring it.
101 ///
102 /// REF: references/vendored/Mumble.proto : `UserState.channel_id` sent by a
103 /// client for its own session.
104 RequestedChannel {
105 connection: ConnectionId,
106 channel: ChannelKey,
107 },
108 /// The client asked to mute or deafen **itself**.
109 ///
110 /// A request like the others: the flags a client sees are the ones the
111 /// flavor renders, so refusing is simply rendering nothing new. A flavor
112 /// that grants it stores the pair and hands it back through
113 /// [`crate::build::ShardBuilder::user_flags`].
114 ///
115 /// `None` means the client said nothing about that flag, so whatever the
116 /// flavor currently renders stands. The runtime holds no copy of the pair:
117 /// that state belongs to the flavor, and keeping a second one here is how
118 /// the two start disagreeing.
119 ///
120 /// REF: references/mumble/src/mumble/ServerHandler.cpp :
121 /// `setSelfMuteDeafState` sends a `UserState` carrying both flags.
122 RequestedSelfState {
123 connection: ConnectionId,
124 self_mute: Option<bool>,
125 self_deaf: Option<bool>,
126 },
127 /// The client invoked a context action this shard had offered it.
128 ///
129 /// Everything is already resolved against what that connection was actually
130 /// granted and can actually see: the key was offered to it, the target is
131 /// visible to it, and the place matches the bits the flavor declared. What
132 /// the action *means* is the flavor's business alone, including doing
133 /// nothing and refusing through [`crate::reply::Reply::refuse`].
134 ///
135 /// REF: references/mumble/src/mumble/MainWindow.cpp : `context_triggered`
136 /// sends back the identifier the server stored, with the selected user
137 /// and channel.
138 InvokedAction {
139 connection: ConnectionId,
140 action: ActionKey,
141 on: ActionTarget,
142 },
143 /// The client typed something and aimed it somewhere.
144 ///
145 /// A request like every other: the target is already resolved against what
146 /// this connection can see, and the channel it names is one the flavor
147 /// rendered as writable, but **nothing has been delivered**. A flavor that
148 /// ignores this event delivers nothing, which is the honest default for a
149 /// runtime whose whole subject is who may know what.
150 ///
151 /// [`crate::reply::Reply::relay`] is the one line that carries it out, and
152 /// the flavor is free to rewrite the audience, the text, or both.
153 ///
154 /// REF: references/mumble/src/murmur/Messages.cpp : `msgTextMessage` resolves
155 /// the targets against the sender's view, then routes.
156 Said {
157 connection: ConnectionId,
158 to: Audience,
159 text: String,
160 },
161}
162
163/// What a context action was invoked on.
164///
165/// In the flavor's own vocabulary rather than the wire's: a key for a channel,
166/// an occupant for a user, exactly like [`VoiceEvent::RequestedChannel`].
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ActionTarget {
169 /// Nothing was selected: the action was invoked on the server itself.
170 Server,
171 Channel(ChannelKey),
172 User(Occupant),
173}
174
175/// What a flavor writes.
176///
177/// `Send + 'static` and deliberately **not** `Sync`: the runtime never needs to
178/// share the logic, so it imposes no synchronization. A concrete flavor may
179/// happen to be `Sync` if it holds one, which is its own business.
180///
181/// # External business input
182///
183/// [`ShardHandle::send`] is deliberately limited to [`ShardCommand`]: those are
184/// runtime commands, not an extensible business mailbox. A concrete flavor owns
185/// the transport for its own vocabulary instead. For an ordered event stream,
186/// keep an `mpsc::Receiver<Event>` in the logic and give its senders to the
187/// integration. For last-value-wins state, the same boundary can use a
188/// `watch::Receiver` holding an immutable snapshot.
189///
190/// Slow or asynchronous work happens on the producer side. Once it has produced
191/// an owned event or snapshot, the producer publishes it and then calls
192/// [`ShardHandle::wake`]:
193///
194/// ```no_run
195/// use tokio::sync::mpsc;
196/// use mumble_server_runtime_shard::{
197/// ConnectionId, Reply, ScopeSet, ShardBuilder, ShardHandle, ShardLogic, VoiceEvent,
198/// };
199///
200/// struct Notification;
201/// struct GameEvent;
202/// struct GameState;
203///
204/// impl GameState {
205/// fn apply(&mut self, _event: GameEvent) {}
206///
207/// fn render(&self, _out: &mut ShardBuilder<'_>) {}
208/// }
209///
210/// struct GameLogic {
211/// inbox: mpsc::Receiver<GameEvent>,
212/// state: GameState,
213/// }
214///
215/// fn build_logic() -> (mpsc::Sender<GameEvent>, GameLogic) {
216/// let (sender, inbox) = mpsc::channel(64);
217/// (sender, GameLogic { inbox, state: GameState })
218/// }
219///
220/// impl ShardLogic for GameLogic {
221/// fn render(&mut self, out: &mut ShardBuilder<'_>) {
222/// while let Ok(event) = self.inbox.try_recv() {
223/// self.state.apply(event);
224/// }
225/// self.state.render(out);
226/// }
227///
228/// fn observation(&mut self, _connection: ConnectionId) -> ScopeSet {
229/// ScopeSet::NONE
230/// }
231///
232/// fn observe(&mut self, _event: &VoiceEvent, _out: &mut Reply) {}
233/// }
234///
235/// async fn calculate(_notification: Notification) -> GameEvent {
236/// GameEvent
237/// }
238///
239/// async fn publish(
240/// notification: Notification,
241/// sender: &mpsc::Sender<GameEvent>,
242/// handle: &ShardHandle,
243/// ) -> Result<(), mpsc::error::SendError<GameEvent>> {
244/// let event = calculate(notification).await;
245/// sender.send(event).await?;
246/// handle.wake();
247/// Ok(())
248/// }
249/// ```
250///
251/// `wake` carries no data. It only says that the desired state may have
252/// changed, so several wake-ups may be coalesced into one reconciliation. The
253/// channel or snapshot remains the source of truth. Publishing before waking
254/// ensures that the next [`ShardLogic::render`] can observe the change.
255///
256/// Keeping `render` synchronous is intentional: it must not wait for I/O or
257/// perform blocking work. Its `&mut self` receiver lets it drain the
258/// flavor-owned channel and update local state without a lock. Any `.await`
259/// belongs before publication, outside the shard task, as in the example.
260pub trait ShardLogic: Send + 'static {
261 /// Build the shared view, the private overlays and the audio relation.
262 ///
263 /// No viewer parameter: what is SHARED cannot depend on who is looking.
264 fn render(&mut self, out: &mut ShardBuilder<'_>);
265
266 /// What this connection observes of the shared view.
267 ///
268 /// Called once per connection per turn, so it **must** stay a small `Copy`
269 /// value. Returning something bigger is how the cost goes quadratic again.
270 fn observation(&mut self, connection: ConnectionId) -> ScopeSet;
271
272 /// A voice fact happened. The flavor alone decides what to do with it.
273 ///
274 /// `out` is the only way a flavor speaks: everything else it wants to change
275 /// belongs to the next [`ShardLogic::render`]. See [`Reply`] for why the two
276 /// doors are separate.
277 fn observe(&mut self, event: &VoiceEvent, out: &mut Reply);
278}
279
280/// A command for a shard's task.
281#[derive(Debug)]
282pub enum ShardCommand {
283 Attach {
284 connection: ConnectionId,
285 /// Shared with the connection's writer task, which is what lets a
286 /// migration hand the same queue to the next shard.
287 queue: Arc<OutboundQueue>,
288 /// The cell the voice plane gates on. Owned by the runtime rather than
289 /// by the shard, so a migration does not have to republish it.
290 cursor: Arc<AtomicU64>,
291 /// What the client already holds. Empty for a fresh connection, and the
292 /// previous shard's composed view for a migration.
293 held: ShardView,
294 /// Fired once the connection's first transition has been accepted, so
295 /// the handshake knows when it may send `ServerSync`.
296 ready: Option<oneshot::Sender<()>>,
297 },
298 Detach {
299 connection: ConnectionId,
300 reason: String,
301 /// Set when the connection is moving to another shard rather than
302 /// leaving. It receives the view the client still holds, and the
303 /// teardown is **skipped**: the destination plans one transition from
304 /// that view instead.
305 handover: Option<Handover>,
306 },
307 /// A connection's queue drained: retry for **that one alone**, in O(1).
308 Drained(ConnectionId),
309 /// The client asked to enter a channel. Resolved against this connection's
310 /// own view and reported to the flavor, or refused.
311 Requested {
312 connection: ConnectionId,
313 channel: ChannelId,
314 },
315 /// The client asked to mute or deafen itself. Reported to the flavor, which
316 /// alone decides what the view ends up saying.
317 RequestedSelfState {
318 connection: ConnectionId,
319 self_mute: Option<bool>,
320 self_deaf: Option<bool>,
321 },
322 /// The client asked what it may do in a channel. Answered from the render,
323 /// for that connection alone.
324 QueriedPermissions {
325 connection: ConnectionId,
326 channel: ChannelId,
327 },
328 /// The client asked what the server publishes about a user. Answered only
329 /// for a user this connection can actually see.
330 QueriedUserStats {
331 connection: ConnectionId,
332 target: SessionId,
333 },
334 /// The client invoked a context action. Validated against what this
335 /// connection was offered and what it can see, then reported to the flavor.
336 ///
337 /// The wire identifier travels as it arrived: the gateway does not know
338 /// which actions exist, and reading it back is part of the validation.
339 InvokedAction {
340 connection: ConnectionId,
341 action: String,
342 session: Option<SessionId>,
343 channel: Option<ChannelId>,
344 },
345 /// The client sent a text message. Resolved against this connection's own
346 /// view and reported to the flavor, or refused.
347 ///
348 /// The gateway has already checked the shape, the length and the rate; what
349 /// is left is the only question it cannot answer, which is whether this
350 /// connection may name that target at all.
351 Said {
352 connection: ConnectionId,
353 to: TextTarget,
354 message: String,
355 },
356}
357
358impl ShardCommand {
359 /// Attach a connection that holds nothing yet: a fresh arrival.
360 #[must_use]
361 pub fn attach(connection: ConnectionId, queue: Arc<OutboundQueue>) -> ShardCommand {
362 ShardCommand::Attach {
363 connection,
364 queue,
365 cursor: Arc::new(AtomicU64::new(0)),
366 held: ShardView::empty(),
367 ready: None,
368 }
369 }
370
371 /// Detach a connection that is leaving for good.
372 #[must_use]
373 pub fn detach(connection: ConnectionId, reason: impl Into<String>) -> ShardCommand {
374 ShardCommand::Detach {
375 connection,
376 reason: reason.into(),
377 handover: None,
378 }
379 }
380}
381
382/// Where a migrating connection's held view is sent.
383#[derive(Debug)]
384pub struct Handover {
385 /// The shard the connection is moving to, reported to the flavor.
386 pub to: ShardId,
387 /// Receives the composed view the client still holds.
388 pub view: oneshot::Sender<ShardView>,
389}
390
391/// One connection attached to a shard, and its committed state.
392#[derive(Debug)]
393pub struct AttachedConnection {
394 id: ConnectionId,
395 session: SessionId,
396 /// How far through the SHARED journal it has been advanced.
397 cursor: u64,
398 /// What it observes of the shared view.
399 see: ScopeSet,
400 /// What PRIVATE elements it has received.
401 overlay_sent: Overlay,
402 /// What context actions it has been offered. Same discipline as the overlay:
403 /// what the client **holds**, not what the last render wanted it to hold.
404 actions_sent: Actions,
405 /// What the client holds, when this shard cannot derive it from its own
406 /// previous view: right after an attach, and right after a migration.
407 ///
408 /// `Some` only until the first transition is accepted, and then never again.
409 /// Keeping one view per connection permanently is exactly the O(N·W) memory
410 /// this whole design exists to avoid, so the field is a transient rather
411 /// than a cache.
412 held: Option<ShardView>,
413 /// Fired once, when the connection's first transition lands.
414 ready: Option<oneshot::Sender<()>>,
415 queue: Arc<OutboundQueue>,
416 /// Read by the voice plane to gate newly granted routes (guide 9.5).
417 shared_cursor: Arc<AtomicU64>,
418}
419
420impl AttachedConnection {
421 #[must_use]
422 pub fn id(&self) -> ConnectionId {
423 self.id
424 }
425
426 #[must_use]
427 pub fn session(&self) -> SessionId {
428 self.session
429 }
430
431 #[must_use]
432 pub fn cursor(&self) -> u64 {
433 self.cursor
434 }
435
436 #[must_use]
437 pub fn observation(&self) -> ScopeSet {
438 self.see
439 }
440
441 /// The private elements this connection has actually received.
442 #[must_use]
443 pub fn overlay_sent(&self) -> &Overlay {
444 &self.overlay_sent
445 }
446
447 /// The context actions this connection has actually been offered.
448 #[must_use]
449 pub fn actions_sent(&self) -> &Actions {
450 &self.actions_sent
451 }
452
453 /// Whether the queue has refused something unrecoverable.
454 #[must_use]
455 pub fn must_close(&self) -> bool {
456 self.queue.must_close()
457 }
458
459 /// The cursor cell the voice plane gates on.
460 #[must_use]
461 pub fn shared_cursor(&self) -> Arc<AtomicU64> {
462 Arc::clone(&self.shared_cursor)
463 }
464
465 /// Advance the committed quadruplet. The only place the four move, and they
466 /// move together.
467 fn commit(&mut self, cursor: u64, see: ScopeSet, overlay: Overlay, actions: Actions) {
468 self.cursor = cursor;
469 self.see = see;
470 self.overlay_sent = overlay;
471 self.actions_sent = actions;
472 self.shared_cursor.store(cursor, Ordering::Relaxed);
473 // Whatever the client held before this transition is now described by
474 // the triplet, so the transient copy is dropped rather than kept.
475 self.held = None;
476 if let Some(ready) = self.ready.take() {
477 // Nobody waiting is the normal case for every connection but the
478 // one still in its handshake.
479 let _awaited = ready.send(());
480 }
481 }
482
483 /// The view this connection's client actually holds right now.
484 fn composed(&self, shared: &ShardView) -> ShardView {
485 match &self.held {
486 Some(held) => held.clone(),
487 None => shared.restrict(self.see).compose(&self.overlay_sent),
488 }
489 }
490}
491
492/// What one [`Shard::reconcile`] did. Everything an operator or a test needs.
493#[derive(Debug, Clone, Default, PartialEq, Eq)]
494pub struct ReconcileReport {
495 /// The version after this turn.
496 pub version: u64,
497 /// Whether a new version was published.
498 pub published: bool,
499 /// How many operations the shared delta held.
500 pub delta_len: usize,
501 /// Connections that took the slow path because their observation moved.
502 pub replanned: Vec<ConnectionId>,
503 /// Whether the audio routing table was recompiled.
504 pub routing_recompiled: bool,
505 /// Connections that must be torn down.
506 pub closed: Vec<ConnectionId>,
507 /// Set when the render was refused: the previous view was kept.
508 pub refused: Option<BuildError>,
509}
510
511/// A shard: unit of ownership, scheduling, rendering, id allocation and audio
512/// routing.
513pub struct Shard<L: ShardLogic> {
514 id: ShardId,
515 logic: L,
516 ids: SharedIds,
517 view: ShardView,
518 journal: Journal,
519 version: u64,
520 connections: BTreeMap<ConnectionId, AttachedConnection>,
521 /// The version each session first appeared at, threaded through so it
522 /// survives recompilation.
523 since: BTreeMap<SessionId, u64>,
524 /// The inputs the routing table was last compiled from. Comparing against
525 /// them is what makes a channel rename cost nothing on the audio plane.
526 declared_audio: AudioRelation,
527 /// Part of the same comparison: who was muted or deafened last time.
528 declared_silence: Silence,
529 session_of: BTreeMap<ConnectionId, SessionId>,
530 routing: watch::Sender<Arc<AudioRouting>>,
531 /// Where what this shard cannot do itself is sent. `None` for a shard that
532 /// belongs to no runtime, which is a real state - a test, a benchmark - and
533 /// not a missing wire, so it is reported rather than assumed away.
534 effects: Option<Effects>,
535}
536
537impl<L: ShardLogic> Shard<L> {
538 /// A shard with an allocator of its own.
539 ///
540 /// Correct for a runtime that will only ever hold one shard. As soon as a
541 /// connection can move, every shard must share one allocator: see
542 /// [`Shard::with_ids`] and [`SharedIds`].
543 #[must_use]
544 pub fn new(id: ShardId, logic: L) -> Shard<L> {
545 Shard::with_ids(id, logic, SharedIds::new())
546 }
547
548 /// A shard drawing its identifiers from a runtime-wide allocator.
549 #[must_use]
550 pub fn with_ids(id: ShardId, logic: L, ids: SharedIds) -> Shard<L> {
551 let (routing, _) = watch::channel(Arc::new(AudioRouting::default()));
552 Shard {
553 id,
554 logic,
555 ids,
556 view: ShardView::empty(),
557 journal: Journal::new(),
558 version: 0,
559 connections: BTreeMap::new(),
560 since: BTreeMap::new(),
561 declared_audio: AudioRelation::default(),
562 declared_silence: Silence::default(),
563 session_of: BTreeMap::new(),
564 routing,
565 effects: None,
566 }
567 }
568
569 /// Wire this shard's effects to a runtime.
570 ///
571 /// Kept out of the constructors on purpose: a shard is complete without one,
572 /// and only the code that owns several shards can honour a move between two
573 /// of them.
574 pub fn route_effects(&mut self, effects: Effects) {
575 self.effects = Some(effects);
576 }
577
578 #[must_use]
579 pub fn id(&self) -> ShardId {
580 self.id
581 }
582
583 #[must_use]
584 pub fn version(&self) -> u64 {
585 self.version
586 }
587
588 #[must_use]
589 pub fn view(&self) -> &ShardView {
590 &self.view
591 }
592
593 #[must_use]
594 pub fn connection(&self, connection: ConnectionId) -> Option<&AttachedConnection> {
595 self.connections.get(&connection)
596 }
597
598 /// The attached connections, in id order.
599 pub fn connections(&self) -> impl Iterator<Item = &AttachedConnection> {
600 self.connections.values()
601 }
602
603 /// The flavor's own state.
604 ///
605 /// The shard owns the logic outright, so this is how a composition binary
606 /// or a test reaches the business model it handed over. A flavor driven by
607 /// its own channel does not need it.
608 pub fn logic_mut(&mut self) -> &mut L {
609 &mut self.logic
610 }
611
612 /// A reader of the audio routing table, for the voice plane.
613 #[must_use]
614 pub fn routing(&self) -> watch::Receiver<Arc<AudioRouting>> {
615 self.routing.subscribe()
616 }
617
618 /// Apply a command. Never renders: the caller reconciles afterwards.
619 pub fn handle(&mut self, command: ShardCommand) {
620 match command {
621 ShardCommand::Attach {
622 connection,
623 queue,
624 cursor,
625 held,
626 ready,
627 } => self.attach(connection, queue, cursor, held, ready),
628 ShardCommand::Detach {
629 connection,
630 reason,
631 handover,
632 } => self.detach(connection, &reason, handover),
633 ShardCommand::Drained(connection) => self.retry(connection),
634 ShardCommand::Requested {
635 connection,
636 channel,
637 } => self.requested(connection, channel),
638 ShardCommand::RequestedSelfState {
639 connection,
640 self_mute,
641 self_deaf,
642 } => self.requested_self_state(connection, self_mute, self_deaf),
643 ShardCommand::QueriedPermissions {
644 connection,
645 channel,
646 } => self.queried_permissions(connection, channel),
647 ShardCommand::QueriedUserStats { connection, target } => {
648 self.queried_user_stats(connection, target);
649 }
650 ShardCommand::InvokedAction {
651 connection,
652 action,
653 session,
654 channel,
655 } => self.invoked(connection, &action, session, channel),
656 ShardCommand::Said {
657 connection,
658 to,
659 message,
660 } => self.said(connection, to, message),
661 }
662 }
663
664 /// Attach a connection.
665 ///
666 /// It starts observing **nothing** and holding `held`, which is what makes
667 /// attaching an ordinary scope change rather than a mechanism of its own:
668 /// the next reconcile sees a connection that holds a view this shard did not
669 /// produce, takes the slow path, and plans one transition from it. Attach,
670 /// detach and migrate are then all the same code (guide 9.6), and a
671 /// migration inherits the no-flicker property for free.
672 fn attach(
673 &mut self,
674 connection: ConnectionId,
675 queue: Arc<OutboundQueue>,
676 cursor: Arc<AtomicU64>,
677 held: ShardView,
678 ready: Option<oneshot::Sender<()>>,
679 ) {
680 let Ok(session) = self.ids.session(Occupant::Connection(connection)) else {
681 // The session space is exhausted. Refuse the connection rather than
682 // attaching one that can never be rendered.
683 queue.mark_fatal();
684 return;
685 };
686
687 cursor.store(self.version, Ordering::Relaxed);
688 self.connections.insert(
689 connection,
690 AttachedConnection {
691 id: connection,
692 session,
693 cursor: self.version,
694 see: ScopeSet::NONE,
695 overlay_sent: Overlay::default(),
696 actions_sent: Actions::default(),
697 held: Some(held),
698 ready,
699 queue,
700 shared_cursor: cursor,
701 },
702 );
703 self.tell(&VoiceEvent::Connected { connection });
704 }
705
706 /// Detach a connection: it stops hearing and being heard immediately.
707 ///
708 /// A connection that is **leaving** is told to tear its view down. One that
709 /// is **migrating** is not: its held view is handed to the destination,
710 /// which plans a single transition onto its own tree. Tearing down first
711 /// would be worse than wasteful, it would be wrong - the client keeps itself
712 /// in its own model after a `UserRemove` for its own session, so the
713 /// following `ChannelRemove` would look like a removal of an occupied
714 /// channel and the client would disconnect over a protocol violation.
715 ///
716 /// REF: references/mumble/src/mumble/Messages.cpp : `msgUserRemove` skips
717 /// `removeUser` when the victim is self; `msgChannelRemove` disconnects
718 /// when `UserModel::removeChannel(c, true)` refuses an occupied channel.
719 fn detach(&mut self, connection: ConnectionId, reason: &str, handover: Option<Handover>) {
720 let Some(mut attached) = self.connections.remove(&connection) else {
721 return;
722 };
723
724 let held = attached.composed(&self.view);
725 if let Some(handover) = handover {
726 // An action belongs to the shard that offered it, and the destination
727 // starts from an empty registry because that is what an attach says.
728 // Without this the client would keep buttons nobody can ever withdraw,
729 // and invoking one would reach a shard that never granted it.
730 let withdrawn = crate::emit::actions(&attached.actions_sent, &Actions::default());
731 if !withdrawn.is_empty()
732 && let Err(refused) = attached.queue.try_send_all(withdrawn)
733 {
734 eprintln!(
735 "mumble-server-runtime-shard: shard {:?}: {connection:?} leaves with context actions this \
736 shard could not withdraw: {refused}",
737 self.id
738 );
739 }
740 // A closed receiver means the migration was abandoned between the
741 // two commands; the connection is then attached nowhere, and its
742 // own task tears it down.
743 let _received = handover.view.send(held);
744 self.tell(&VoiceEvent::Migrated {
745 connection,
746 to: handover.to,
747 });
748 return;
749 }
750
751 let mut ops: Vec<PlanOp> = plan(&held, &ShardView::empty())
752 .into_iter()
753 .map(|planned| planned.op)
754 .collect();
755 collapse(&mut ops);
756 if !ops.is_empty() {
757 // A connection on its way out has nothing to retry with, so a
758 // refusal here is an outcome rather than a fault.
759 match attached.queue.try_send_all(emit(&ops, attached.session)) {
760 Ok(()) => attached.commit(
761 self.version,
762 ScopeSet::NONE,
763 Overlay::default(),
764 Actions::default(),
765 ),
766 Err(_refused) => attached.queue.mark_fatal(),
767 }
768 }
769
770 self.tell(&VoiceEvent::Disconnected {
771 connection,
772 reason: reason.to_owned(),
773 });
774 }
775
776 /// Resolve a client's channel request against what that client can see.
777 ///
778 /// Refusing an id the connection does not observe is not politeness: it
779 /// keeps a guessed number from working as an existence oracle for channels
780 /// in another team's subtree.
781 fn requested(&mut self, connection: ConnectionId, channel: ChannelId) {
782 let Some(attached) = self.connections.get(&connection) else {
783 return;
784 };
785 let key = self
786 .visible_channel(attached, channel)
787 .map(|rendered| rendered.key);
788
789 match key {
790 Some(key) => self.tell(&VoiceEvent::RequestedChannel {
791 connection,
792 channel: key,
793 }),
794 // Fail closed and stay audible: an operator seeing this repeatedly
795 // is looking at either a stale client or a probe.
796 None => eprintln!(
797 "mumble-server-runtime-shard: shard {:?}: {connection:?} asked for channel {channel:?}, which it \
798 cannot see",
799 self.id
800 ),
801 }
802 }
803
804 /// A channel as this connection can see it: shared and observed, or held
805 /// from before its first transition, or private to it.
806 ///
807 /// The three places are the whole of what a client may legitimately name.
808 /// Answering about anything else, however harmlessly, would turn a guessed
809 /// identifier into an existence oracle for another team's subtree.
810 fn visible_channel<'a>(
811 &'a self,
812 attached: &'a AttachedConnection,
813 channel: ChannelId,
814 ) -> Option<&'a Channel> {
815 self.view
816 .channels
817 .get(&channel)
818 .filter(|rendered| attached.see.sees(rendered.scope))
819 .or_else(|| {
820 attached
821 .held
822 .as_ref()
823 .and_then(|held| held.channels.get(&channel))
824 })
825 .or_else(|| attached.overlay_sent.channels.get(&channel))
826 }
827
828 /// A user as this connection can see it. The same three places, and the same
829 /// reason.
830 fn seen_user<'a>(
831 &'a self,
832 attached: &'a AttachedConnection,
833 session: SessionId,
834 ) -> Option<&'a User> {
835 self.view
836 .users
837 .get(&session)
838 .filter(|user| attached.see.sees(user.scope))
839 .or_else(|| {
840 attached
841 .held
842 .as_ref()
843 .and_then(|held| held.users.get(&session))
844 })
845 .or_else(|| attached.overlay_sent.users.get(&session))
846 }
847
848 /// Resolve an invocation against what this connection was granted and what
849 /// it can see, then report it.
850 ///
851 /// Three refusals, all silent to the client and audible to an operator, for
852 /// the same reason [`Shard::requested`] refuses: an answer that varied with
853 /// whether the target exists would turn a guessed identifier into an oracle.
854 ///
855 /// The target is chosen by the bits the flavor declared, most specific
856 /// first, rather than by what the message carries. The client reads the
857 /// tree's current selection whichever menu the action came from, so a server
858 /// action routinely arrives with a session and a channel it has nothing to
859 /// do with.
860 ///
861 /// REF: references/mumble/src/mumble/MainWindow.cpp : `context_triggered`
862 /// fills `session` and `channel_id` from `qtvUsers->currentIndex()`, while
863 /// server actions live in `qmServer` and are never told about it.
864 fn invoked(
865 &mut self,
866 connection: ConnectionId,
867 action: &str,
868 session: Option<SessionId>,
869 channel: Option<ChannelId>,
870 ) {
871 let Some(attached) = self.connections.get(&connection) else {
872 return;
873 };
874
875 let Some(key) = crate::emit::action_key(action) else {
876 eprintln!(
877 "mumble-server-runtime-shard: shard {:?}: {connection:?} invoked {action:?}, which is not a name \
878 this server writes",
879 self.id
880 );
881 return;
882 };
883 // What the client holds, not what the last render wanted it to hold: an
884 // action withdrawn in a turn this connection has not received yet is
885 // still legitimately on its screen. The flavor keeps the last word and
886 // can refuse out loud.
887 let Some(offered) = attached.actions_sent.get(&key) else {
888 eprintln!(
889 "mumble-server-runtime-shard: shard {:?}: {connection:?} invoked action {key:?}, which it was \
890 never offered",
891 self.id
892 );
893 return;
894 };
895 let on = offered.on;
896
897 let target = if let Some(session) = session.filter(|_| on.covers(On::USER)) {
898 // Named a user and the action is about users: it must be a user this
899 // connection has been told about, and no fallback softens that.
900 self.seen_user(attached, session)
901 .map(|user| ActionTarget::User(user.occupant))
902 } else if let Some(channel) = channel.filter(|_| on.covers(On::CHANNEL)) {
903 self.visible_channel(attached, channel)
904 .map(|rendered| ActionTarget::Channel(rendered.key))
905 } else if on.covers(On::SERVER) {
906 Some(ActionTarget::Server)
907 } else {
908 None
909 };
910
911 match target {
912 Some(on) => self.tell(&VoiceEvent::InvokedAction {
913 connection,
914 action: key,
915 on,
916 }),
917 None => eprintln!(
918 "mumble-server-runtime-shard: shard {:?}: {connection:?} invoked action {key:?} on a target it \
919 cannot see, or that the action was not offered on",
920 self.id
921 ),
922 }
923 }
924
925 /// Resolve where a client aimed a text message, then report it.
926 ///
927 /// Two different refusals, and the difference is the whole point:
928 ///
929 /// - A target this connection cannot see is **silent** to the client and
930 /// audible to an operator, like every other unseen target here. An answer
931 /// that varied with whether the id exists would turn a guessed number into
932 /// an existence oracle.
933 /// - A target it *can* see but that the flavor rendered read-only is refused
934 /// **out loud**, with the missing permission. Nothing leaks: the client is
935 /// already holding that channel, and it asked to do something the answer to
936 /// `PermissionQuery` had already denied.
937 ///
938 /// A private message is checked against the recipient's channel rather than
939 /// the sender's, which is what the reference server does.
940 ///
941 /// REF: references/mumble/src/murmur/Messages.cpp : `msgTextMessage` checks
942 /// `ChanACL::TextMessage` on each named channel, and on `u->cChannel` for a
943 /// directly addressed user.
944 fn said(&mut self, connection: ConnectionId, to: TextTarget, message: String) {
945 let Some(attached) = self.connections.get(&connection) else {
946 return;
947 };
948 let session = attached.session;
949
950 // Resolved into the flavor's vocabulary, and only through what this
951 // connection actually holds. `writable_in` is the channel whose
952 // `can_text` decides: the one named, or the recipient's own for a
953 // private message.
954 let resolved = match to {
955 TextTarget::Session(target) => self
956 .seen_user(attached, target)
957 .map(|user| (Audience::User(user.occupant), user.channel)),
958 TextTarget::Channel(channel) => self
959 .visible_channel(attached, channel)
960 .map(|rendered| (Audience::Channel(rendered.key), channel)),
961 TextTarget::Tree(channel) => self
962 .visible_channel(attached, channel)
963 .map(|rendered| (Audience::Tree(rendered.key), channel)),
964 };
965 let Some((audience, writable_in)) = resolved else {
966 eprintln!(
967 "mumble-server-runtime-shard: shard {:?}: {connection:?} wrote to {to:?}, which it cannot see",
968 self.id
969 );
970 return;
971 };
972
973 // For a tree that is its root, deliberately: the flavor chooses the
974 // audience it actually delivers to, so refusing further down would be
975 // refusing on behalf of a decision it has not made yet.
976 let writable = self
977 .visible_channel(attached, writable_in)
978 .is_some_and(|rendered| rendered.can_text);
979 if !writable {
980 self.answer(
981 attached,
982 crate::emit::denied_permission(
983 session,
984 writable_in,
985 crate::emit::perm::TEXT_MESSAGE,
986 ),
987 );
988 return;
989 }
990
991 self.tell(&VoiceEvent::Said {
992 connection,
993 to: audience,
994 text: message,
995 });
996 }
997
998 /// Answer "what may I do in that channel", from the current render.
999 ///
1000 /// The flavor is not consulted and has nothing to decide: it already said
1001 /// everything it had to say by rendering the channel, and a query is not an
1002 /// intent. Keeping it here also keeps it cheap - one lookup, one message -
1003 /// where a round trip through business code would cost a turn.
1004 fn queried_permissions(&self, connection: ConnectionId, channel: ChannelId) {
1005 let Some(attached) = self.connections.get(&connection) else {
1006 return;
1007 };
1008 match self.visible_channel(attached, channel) {
1009 Some(rendered) => self.answer(attached, crate::emit::permission_query(rendered)),
1010 None => eprintln!(
1011 "mumble-server-runtime-shard: shard {:?}: {connection:?} queried permissions on channel \
1012 {channel:?}, which it cannot see",
1013 self.id
1014 ),
1015 }
1016 }
1017
1018 /// Answer "what do you publish about that user", for a user it can see.
1019 fn queried_user_stats(&self, connection: ConnectionId, target: SessionId) {
1020 let Some(attached) = self.connections.get(&connection) else {
1021 return;
1022 };
1023 if self.seen_user(attached, target).is_some() {
1024 self.answer(attached, crate::emit::user_stats(target));
1025 } else {
1026 eprintln!(
1027 "mumble-server-runtime-shard: shard {:?}: {connection:?} queried stats about session {target:?}, \
1028 which it cannot see",
1029 self.id
1030 );
1031 }
1032 }
1033
1034 /// Push one message that answers a question, rather than describing a
1035 /// change.
1036 ///
1037 /// Refusing it is an outcome, not a fault: a query the client can simply ask
1038 /// again is worth less than the transitions queued ahead of it, so a
1039 /// congested connection drops the answer instead of being torn down for it.
1040 fn answer(&self, attached: &AttachedConnection, message: ControlMessage) {
1041 if let Err(refused) = attached.queue.try_send_all(vec![message]) {
1042 eprintln!(
1043 "mumble-server-runtime-shard: shard {:?}: dropping an answer for {:?}: {refused}",
1044 self.id, attached.id
1045 );
1046 }
1047 }
1048
1049 /// Report an event to the flavor, and deliver whatever it said.
1050 ///
1051 /// The single door between the runtime and the business model: the flavor
1052 /// takes the event, updates its own state, and writes into a [`Reply`] that
1053 /// borrows nothing, so what it says is delivered here rather than from
1054 /// inside its own call.
1055 ///
1056 /// A word aimed at a connection this shard does not hold is dropped with a
1057 /// log rather than refused loudly. That is the normal shape of a departure:
1058 /// [`Shard::detach`] removes the connection **before** reporting it, so a
1059 /// flavor saying goodbye is speaking to a socket that is already leaving.
1060 fn tell(&mut self, event: &VoiceEvent) {
1061 let mut reply = Reply::default();
1062 self.logic.observe(event, &mut reply);
1063
1064 // Words first: a flavor that says goodbye and switches in the same
1065 // breath must have the farewell on the socket before the connection is
1066 // handed over.
1067 for (connection, words) in reply.drain() {
1068 let Some(attached) = self.connections.get(&connection) else {
1069 eprintln!(
1070 "mumble-server-runtime-shard: shard {:?}: dropping {} word(s) for {connection:?}, which is \
1071 not attached here",
1072 self.id,
1073 words.len()
1074 );
1075 continue;
1076 };
1077 let messages: Vec<ControlMessage> = words
1078 .iter()
1079 .map(|word| crate::emit::spoken(attached.session, word))
1080 .collect();
1081 // Same bargain as an answer: speech the client can live without is
1082 // worth less than the transitions queued ahead of it.
1083 if let Err(refused) = attached.queue.try_send_all(messages) {
1084 eprintln!(
1085 "mumble-server-runtime-shard: shard {:?}: dropping what the flavor said to {connection:?}: \
1086 {refused}",
1087 self.id
1088 );
1089 }
1090 }
1091
1092 for spoken in reply.drain_spoken() {
1093 self.deliver(&spoken);
1094 }
1095
1096 for effect in reply.drain_effects() {
1097 match &self.effects {
1098 Some(route) => route(effect),
1099 None => eprintln!(
1100 "mumble-server-runtime-shard: shard {:?}: dropping {effect:?}: this shard belongs to no \
1101 runtime, so nothing can carry it out",
1102 self.id
1103 ),
1104 }
1105 }
1106 }
1107
1108 /// Expand an audience and carry one message to each of its members.
1109 ///
1110 /// The expansion happens here rather than in the flavor because it is the
1111 /// only place that holds the view - and it is the composed view, per
1112 /// recipient, so an overlay placement counts as being somewhere just as much
1113 /// as a shared one does.
1114 ///
1115 /// Two members are left out, for reasons that are not politeness:
1116 ///
1117 /// - The speaker, which the reference server drops too. A client already
1118 /// printed what it typed.
1119 /// - Anyone who cannot see the speaker. That is the audio coupling rule -
1120 /// a receiver must see the sender - and it is also forced: a `TextMessage`
1121 /// naming a session the recipient does not hold breaks the view invariants
1122 /// the conformance model checks. A flavor that wants everyone to read
1123 /// something whoever said it uses [`crate::reply::Reply::announce`].
1124 ///
1125 /// REF: references/mumble/src/murmur/Messages.cpp : `msgTextMessage` ends on
1126 /// `users.remove(uSource)` before forwarding.
1127 fn deliver(&self, spoken: &Spoken) {
1128 let speaker = spoken
1129 .from
1130 .and_then(|connection| self.connections.get(&connection))
1131 .map(|attached| attached.session);
1132 if spoken.from.is_some() && speaker.is_none() {
1133 eprintln!(
1134 "mumble-server-runtime-shard: shard {:?}: dropping a relay from {:?}, which is not attached here",
1135 self.id, spoken.from
1136 );
1137 return;
1138 }
1139
1140 // Resolved once rather than per recipient: a key stands for the same id
1141 // whoever is looking, and the alternative is a scan of the render for
1142 // every connection in the shard.
1143 let Some(target) = self.resolve(spoken.to) else {
1144 eprintln!(
1145 "mumble-server-runtime-shard: shard {:?}: dropping a message for {:?}, which names nothing this \
1146 shard has rendered",
1147 self.id, spoken.to
1148 );
1149 return;
1150 };
1151
1152 for attached in self.connections.values() {
1153 if Some(attached.id) == spoken.from {
1154 continue;
1155 }
1156 if !self.addressed(attached, target) {
1157 continue;
1158 }
1159 if let Some(session) = speaker
1160 && self.seen_user(attached, session).is_none()
1161 {
1162 eprintln!(
1163 "mumble-server-runtime-shard: shard {:?}: {:?} is in the audience but cannot see session \
1164 {session:?}, so it is skipped rather than told the message came from nobody",
1165 self.id, attached.id
1166 );
1167 continue;
1168 }
1169 // Same bargain as an answer: a message the client can live without
1170 // is worth less than the transitions queued ahead of it.
1171 if let Err(refused) = attached.queue.try_send_all(vec![crate::emit::relayed(
1172 speaker,
1173 target,
1174 &spoken.text,
1175 )]) {
1176 eprintln!(
1177 "mumble-server-runtime-shard: shard {:?}: dropping a relay to {:?}: {refused}",
1178 self.id, attached.id
1179 );
1180 }
1181 }
1182 }
1183
1184 /// Turn what a flavor named into what a client holds.
1185 ///
1186 /// The same identifier for every recipient, which is why this happens once
1187 /// per message: the wire ids are the shard's, not the observer's. What
1188 /// differs per observer is only whether they are *in* the audience, and that
1189 /// is [`Shard::addressed`].
1190 fn resolve(&self, audience: Audience) -> Option<TextTarget> {
1191 match audience {
1192 Audience::User(occupant) => self
1193 .ids
1194 .allocated_session(occupant)
1195 .map(TextTarget::Session),
1196 Audience::Channel(key) => self.channel_of(key).map(TextTarget::Channel),
1197 Audience::Tree(key) => self.channel_of(key).map(TextTarget::Tree),
1198 }
1199 }
1200
1201 /// Whether `attached` is one of the recipients `target` stands for.
1202 ///
1203 /// Read through [`Shard::seen_user`], so a connection placed by an overlay is
1204 /// where its own client believes it is, which is the only answer that makes
1205 /// sense to the person reading the message.
1206 fn addressed(&self, attached: &AttachedConnection, target: TextTarget) -> bool {
1207 match target {
1208 TextTarget::Session(session) => session == attached.session,
1209 TextTarget::Channel(channel) => self
1210 .seen_user(attached, attached.session)
1211 .is_some_and(|user| user.channel == channel),
1212 TextTarget::Tree(root) => self
1213 .seen_user(attached, attached.session)
1214 .is_some_and(|user| self.descends_from(attached, user.channel, root)),
1215 }
1216 }
1217
1218 /// The id a flavor's channel key currently stands for.
1219 ///
1220 /// The render is consulted first because the root does not go through the
1221 /// allocator at all - it *is* [`ChannelId::ROOT`] in every shard - and asking
1222 /// the allocator for it would answer "no such channel" about the one channel
1223 /// everybody can see. The allocator answers for the rest, including a channel
1224 /// that only lives in somebody's overlay.
1225 fn channel_of(&self, key: ChannelKey) -> Option<ChannelId> {
1226 self.view
1227 .channels
1228 .values()
1229 .find(|channel| channel.key == key)
1230 .map(|channel| channel.id)
1231 .or_else(|| self.ids.allocated_channel(self.id, key))
1232 }
1233
1234 /// Whether `channel` is `root` or sits below it, walking the view this
1235 /// connection composes.
1236 ///
1237 /// Bounded by the tree it walks rather than by a counter: the root is its own
1238 /// parent, so the walk always ends there, and a channel whose parent has
1239 /// dropped out of view ends it early.
1240 fn descends_from(
1241 &self,
1242 attached: &AttachedConnection,
1243 channel: ChannelId,
1244 root: ChannelId,
1245 ) -> bool {
1246 let mut current = channel;
1247 loop {
1248 if current == root {
1249 return true;
1250 }
1251 let Some(rendered) = self.visible_channel(attached, current) else {
1252 return false;
1253 };
1254 if rendered.parent == current {
1255 return false;
1256 }
1257 current = rendered.parent;
1258 }
1259 }
1260
1261 /// Report a client's request about its own audio state to the flavor.
1262 ///
1263 /// There is nothing to resolve against the view: the request names no
1264 /// element, only the connection making it, so the visibility question that
1265 /// guards [`Shard::requested`] does not arise. It is still refused for a
1266 /// connection this shard does not hold, so a command that raced a detach
1267 /// cannot reach the flavor as if the connection were still here.
1268 fn requested_self_state(
1269 &mut self,
1270 connection: ConnectionId,
1271 self_mute: Option<bool>,
1272 self_deaf: Option<bool>,
1273 ) {
1274 if !self.connections.contains_key(&connection) {
1275 eprintln!(
1276 "mumble-server-runtime-shard: shard {:?}: {connection:?} asked to change its own state, but it \
1277 is not attached here",
1278 self.id
1279 );
1280 return;
1281 }
1282 self.tell(&VoiceEvent::RequestedSelfState {
1283 connection,
1284 self_mute,
1285 self_deaf,
1286 });
1287 }
1288
1289 /// Retry one connection whose queue drained, without touching the others.
1290 ///
1291 /// A retry is the **fast path and nothing else**: it replays the shared
1292 /// journal from the connection's cursor. A connection that still holds a view
1293 /// this shard did not produce - a fresh attach, or one just handed over by a
1294 /// migration - has no cursor into this journal that means anything, and the
1295 /// transition it is owed is a replan against the render. So it is left alone
1296 /// here, and [`Shard::reconcile`] does it: `held` keeps it in `moved` every
1297 /// turn until the transition actually lands.
1298 ///
1299 /// Pushing it anyway is not merely early, it is destructive: the replay from
1300 /// `cursor` to `head` is empty, `push` concludes there is nothing to send and
1301 /// commits, and committing drops `held`. The view the client really holds is
1302 /// then gone, the replan plans from an empty one, and every element of the
1303 /// source shard survives the migration with nothing left to remove it.
1304 fn retry(&mut self, connection: ConnectionId) {
1305 let head = self.journal.head();
1306 let mut retired_channels = BTreeSet::new();
1307 if let Some(attached) = self.connections.get_mut(&connection) {
1308 if attached.held.is_some() {
1309 return;
1310 }
1311 // The private parts are whatever it last received: a retry replays the
1312 // shared journal, and any private change will arrive with the next
1313 // render. Cloning here keeps `push` free of a self-borrow.
1314 let overlay = attached.overlay_sent.clone();
1315 let actions = attached.actions_sent.clone();
1316 let (_outcome, retired) = push(attached, &self.journal, head, &overlay, &actions);
1317 retired_channels = retired;
1318 }
1319 self.ids.retire_channel_ids(self.id, &retired_channels);
1320 }
1321
1322 /// Render, plan, journal, publish routing, and push to every connection.
1323 pub fn reconcile(&mut self) -> ReconcileReport {
1324 let attached: Vec<ConnectionId> = self.connections.keys().copied().collect();
1325 let observations: BTreeMap<ConnectionId, ScopeSet> = attached
1326 .iter()
1327 .map(|connection| (*connection, self.logic.observation(*connection)))
1328 .collect();
1329
1330 let rendered = {
1331 let mut builder = ShardBuilder::new(&self.ids, self.id, &attached);
1332 self.logic.render(&mut builder);
1333 match builder.finish(&observations) {
1334 Ok(rendered) => rendered,
1335 Err(refused) => {
1336 // Rule 4: keep the current view, close nothing. The
1337 // committed view is still correct and reconnecting would
1338 // only reproduce the same broken render.
1339 return ReconcileReport {
1340 version: self.version,
1341 refused: Some(refused),
1342 ..ReconcileReport::default()
1343 };
1344 }
1345 }
1346 };
1347
1348 let ops = plan(&self.view, &rendered.view);
1349 let moved: Vec<ConnectionId> = attached
1350 .iter()
1351 .copied()
1352 .filter(|connection| {
1353 let observed = observations
1354 .get(connection)
1355 .copied()
1356 .unwrap_or(ScopeSet::NONE);
1357 self.connections.get(connection).is_some_and(|attached| {
1358 // A connection holding a view this shard did not produce
1359 // has to be replanned whatever its observation says, and
1360 // that includes the case where both are empty.
1361 attached.held.is_some() || attached.see != observed
1362 })
1363 })
1364 .collect();
1365 let overlays_changed = attached.iter().any(|connection| {
1366 let fresh = rendered.overlays.get(connection);
1367 let sent = self.connections.get(connection).map(|c| &c.overlay_sent);
1368 match (fresh, sent) {
1369 (Some(fresh), Some(sent)) => fresh != sent,
1370 (Some(fresh), None) => !fresh.is_empty(),
1371 (None, Some(sent)) => !sent.is_empty(),
1372 (None, None) => false,
1373 }
1374 });
1375 // Offering or withdrawing a button changes neither the shared view nor
1376 // any observation, so without this term the render would be believed
1377 // unchanged and the menu would never move.
1378 let actions_changed = attached.iter().any(|connection| {
1379 let fresh = rendered.actions.get(connection);
1380 let sent = self.connections.get(connection).map(|c| &c.actions_sent);
1381 match (fresh, sent) {
1382 (Some(fresh), Some(sent)) => fresh != sent,
1383 (Some(fresh), None) => !fresh.is_empty(),
1384 (None, Some(sent)) => !sent.is_empty(),
1385 (None, None) => false,
1386 }
1387 });
1388
1389 if ops.is_empty() && moved.is_empty() && !overlays_changed && !actions_changed {
1390 return ReconcileReport {
1391 version: self.version,
1392 ..ReconcileReport::default()
1393 };
1394 }
1395
1396 // The version indexes the journal, and only the shared delta goes in
1397 // it: an observation change is replanned from the views directly and an
1398 // overlay change is recomputed from `overlay_sent`. So an empty delta
1399 // must not advance the version, or a connection that stays congested
1400 // would churn versions every turn and push the journal's tail past what
1401 // its peers still need.
1402 let delta_len = ops.len();
1403 let published = !ops.is_empty();
1404 if published {
1405 self.version = self.version.saturating_add(1);
1406 self.journal.push(ops);
1407 }
1408 let retained_channels: BTreeSet<ChannelKey> = rendered
1409 .view
1410 .channels
1411 .values()
1412 .chain(
1413 rendered
1414 .overlays
1415 .values()
1416 .flat_map(|overlay| overlay.channels.values()),
1417 )
1418 .filter(|channel| channel.id != ChannelId::ROOT)
1419 .map(|channel| channel.key)
1420 .collect();
1421 let previous = std::mem::replace(&mut self.view, rendered.view);
1422 // The render is valid and has replaced the desired view. Any key absent
1423 // from both its shared and private parts has now been withdrawn from
1424 // every client view; if it returns, the wire id must not.
1425 self.ids.retain_channels(self.id, &retained_channels);
1426
1427 // A session that has just appeared is dated to this version, so the
1428 // voice plane can tell a receiver has not been told about it yet.
1429 let mut since_changed = false;
1430 for session in self.view.users.keys() {
1431 if !self.since.contains_key(session) {
1432 self.since.insert(*session, self.version);
1433 since_changed = true;
1434 }
1435 }
1436
1437 // Rule 2: publish routing BEFORE pushing views.
1438 //
1439 // The flags are read exactly where the sessions are, and from the same
1440 // presence: whichever rendering of a user is the one the audio plane
1441 // will name it by is also the one that decides whether it may speak.
1442 let mut session_of: BTreeMap<ConnectionId, SessionId> = BTreeMap::new();
1443 let mut silence = Silence::default();
1444 for user in self.view.users.values() {
1445 silence.record(user.session, user.flags);
1446 if let Some(connection) = user.occupant.connection() {
1447 session_of.insert(connection, user.session);
1448 }
1449 }
1450 // A connection with no shared presence is not absent from the runtime,
1451 // only from the shared view: that is exactly what a vanish is. Leaving it
1452 // out here would quietly turn "hears everything, heard by nobody" into
1453 // "takes no part in audio at all", and the flavor would have no way to
1454 // tell the two apart. Whether any given receiver may actually hear it is
1455 // a separate question, and the render already refuses a relation where
1456 // the answer is no.
1457 for (connection, overlay) in &rendered.overlays {
1458 if session_of.contains_key(connection) {
1459 continue;
1460 }
1461 let own = overlay
1462 .users
1463 .values()
1464 .find(|user| user.occupant == Occupant::Connection(*connection));
1465 if let Some(user) = own {
1466 session_of.insert(*connection, user.session);
1467 silence.record(user.session, user.flags);
1468 }
1469 }
1470 // Muting somebody changes neither the declared relation nor the session
1471 // map, so without this term the table would keep every route the flavor
1472 // declared and the flag would be pure decoration.
1473 let routing_recompiled = since_changed
1474 || rendered.audio != self.declared_audio
1475 || session_of != self.session_of
1476 || silence != self.declared_silence;
1477 if routing_recompiled {
1478 let table = compile(&rendered.audio, &session_of, &self.since, &silence);
1479 // `send_replace` rather than `send`: the latter reports "no reader"
1480 // as an error and, crucially, does **not** store the value. A shard
1481 // that rendered before its voice plane subscribed would then keep
1482 // publishing tables into a channel that still held the empty one,
1483 // and every route would be silently missing.
1484 let _previous = self.routing.send_replace(Arc::new(table));
1485 self.declared_audio = rendered.audio;
1486 self.session_of = session_of;
1487 self.declared_silence = silence;
1488 }
1489
1490 let head = self.version;
1491 let moved_set: BTreeSet<ConnectionId> = moved.iter().copied().collect();
1492 let mut closed = Vec::new();
1493 let mut retired_channels = BTreeSet::new();
1494 for attached in self.connections.values_mut() {
1495 let overlay = rendered
1496 .overlays
1497 .get(&attached.id)
1498 .cloned()
1499 .unwrap_or_default();
1500 let actions = rendered
1501 .actions
1502 .get(&attached.id)
1503 .cloned()
1504 .unwrap_or_default();
1505 let (outcome, retired) = if moved_set.contains(&attached.id) {
1506 let new_see = observations
1507 .get(&attached.id)
1508 .copied()
1509 .unwrap_or(ScopeSet::NONE);
1510 replan(
1511 attached, &previous, &self.view, head, new_see, &overlay, &actions,
1512 )
1513 } else {
1514 push(attached, &self.journal, head, &overlay, &actions)
1515 };
1516 retired_channels.extend(retired);
1517 if outcome == Outcome::Close {
1518 attached.queue.mark_fatal();
1519 closed.push(attached.id);
1520 }
1521 }
1522 // Mumble clients remember every ChannelId they have removed. Since ids
1523 // are shard-global, one accepted removal retires that wire id for the
1524 // whole shard; the next render will rotate it for any peers that still
1525 // see the semantic channel.
1526 self.ids.retire_channel_ids(self.id, &retired_channels);
1527
1528 ReconcileReport {
1529 version: self.version,
1530 published,
1531 delta_len,
1532 replanned: moved,
1533 routing_recompiled,
1534 closed,
1535 refused: None,
1536 }
1537 }
1538}
1539
1540/// What a push or replan concluded for one connection.
1541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1542enum Outcome {
1543 /// Delivered, or nothing to deliver.
1544 Advanced,
1545 /// The queue was full. Nothing moved; `Drained` will retry.
1546 Congested,
1547 /// Unrecoverable: tear the connection down and let it rebuild.
1548 Close,
1549}
1550
1551/// Fast path: this connection's observation has not moved.
1552fn push(
1553 attached: &mut AttachedConnection,
1554 journal: &Journal,
1555 head: u64,
1556 overlay: &Overlay,
1557 actions: &Actions,
1558) -> (Outcome, BTreeSet<ChannelId>) {
1559 let Ok(replayed) = journal.replay(attached.cursor, head) else {
1560 // Below the tail: unrepairable from deltas, and dying anyway.
1561 return (Outcome::Close, BTreeSet::new());
1562 };
1563
1564 let shared = filter(replayed, attached.see);
1565 let private = plan_elements(&attached.overlay_sent, overlay);
1566 let mut ops = crate::compose::splice(shared, private);
1567 collapse(&mut ops);
1568
1569 let session = attached.session;
1570 let mut messages = emit(&ops, session);
1571 // Buttons ride with the view rather than beside it: one refusal, one retry,
1572 // and a menu that can never describe a turn the client did not receive.
1573 messages.extend(crate::emit::actions(&attached.actions_sent, actions));
1574
1575 if messages.is_empty() {
1576 // Nothing visible changed for it. Advance the cursor anyway, otherwise
1577 // a connection that sees nothing change would drift off the tail of the
1578 // journal and be closed for no reason.
1579 attached.commit(head, attached.see, overlay.clone(), actions.clone());
1580 return (Outcome::Advanced, BTreeSet::new());
1581 }
1582
1583 match attached.queue.try_send_all(messages) {
1584 Ok(()) => {
1585 attached.commit(head, attached.see, overlay.clone(), actions.clone());
1586 (Outcome::Advanced, retired_channels(&ops))
1587 }
1588 Err(Refused::Congested { .. }) => (Outcome::Congested, BTreeSet::new()),
1589 Err(Refused::TooLarge { .. } | Refused::Closed) => (Outcome::Close, BTreeSet::new()),
1590 }
1591}
1592
1593/// Slow path: this connection's observation moved.
1594///
1595/// It has to learn its whole new subtree and forget the old one. No delta can
1596/// shorten that: the information it lacks is in **no** change, it was already
1597/// there.
1598///
1599/// This is the ordinary planner over two real views, so every ordering rule
1600/// holds by construction. It is deliberately not a detach followed by an
1601/// attach: whatever the two observations have in common - the public channels -
1602/// is left untouched, so the connection's own tree does not flicker.
1603fn replan(
1604 attached: &mut AttachedConnection,
1605 before: &ShardView,
1606 after: &ShardView,
1607 head: u64,
1608 new_see: ScopeSet,
1609 overlay: &Overlay,
1610 actions: &Actions,
1611) -> (Outcome, BTreeSet<ChannelId>) {
1612 let from = attached.composed(before);
1613 let to = after.restrict(new_see).compose(overlay);
1614
1615 let mut ops: Vec<PlanOp> = plan(&from, &to)
1616 .into_iter()
1617 .map(|planned| planned.op)
1618 .collect();
1619 collapse(&mut ops);
1620
1621 let session = attached.session;
1622 let mut messages = emit(&ops, session);
1623 messages.extend(crate::emit::actions(&attached.actions_sent, actions));
1624
1625 if messages.is_empty() {
1626 attached.commit(head, new_see, overlay.clone(), actions.clone());
1627 return (Outcome::Advanced, BTreeSet::new());
1628 }
1629
1630 match attached.queue.try_send_all(messages) {
1631 // Rule 3: the triplet advances together, and only here. The guide's
1632 // sketch assigns the new observation before sending; doing that would
1633 // let a congested connection keep the new observation with the old
1634 // view, and the next fast-path filter would use a scope set the client
1635 // was never told about.
1636 Ok(()) => {
1637 attached.commit(head, new_see, overlay.clone(), actions.clone());
1638 (Outcome::Advanced, retired_channels(&ops))
1639 }
1640 Err(Refused::Congested { .. }) => (Outcome::Congested, BTreeSet::new()),
1641 Err(Refused::TooLarge { .. } | Refused::Closed) => (Outcome::Close, BTreeSet::new()),
1642 }
1643}
1644
1645fn retired_channels(ops: &[PlanOp]) -> BTreeSet<ChannelId> {
1646 ops.iter()
1647 .filter_map(|op| match op {
1648 PlanOp::RemoveChannel(channel) => Some(*channel),
1649 _ => None,
1650 })
1651 .collect()
1652}
1653
1654/// The handle a flavor uses to say "my state changed".
1655///
1656/// Holds no state: a signal and a command channel. It is the only
1657/// business-to-runtime interface, and it carries no data.
1658#[derive(Debug, Clone)]
1659pub struct ShardHandle {
1660 shard: ShardId,
1661 wake: Arc<Notify>,
1662 commands: mpsc::Sender<ShardCommand>,
1663}
1664
1665impl ShardHandle {
1666 #[must_use]
1667 pub fn shard(&self) -> ShardId {
1668 self.shard
1669 }
1670
1671 /// "My state changed, re-render me when you can."
1672 pub fn wake(&self) {
1673 self.wake.notify_one();
1674 }
1675
1676 /// Queue a command for the shard's task.
1677 ///
1678 /// # Errors
1679 ///
1680 /// The command itself, when the shard's task has ended.
1681 pub fn send(
1682 &self,
1683 command: ShardCommand,
1684 ) -> Result<(), mpsc::error::TrySendError<ShardCommand>> {
1685 self.commands.try_send(command)
1686 }
1687}
1688
1689/// How many commands a shard's mailbox holds before senders are refused.
1690const MAILBOX_DEPTH: usize = 256;
1691
1692/// Drive a shard until every handle to it is dropped.
1693///
1694/// Takes the driver halves produced by [`spawn_parts`] and gives the shard back,
1695/// so a caller that wants to inspect or migrate it can.
1696pub async fn run<L: ShardLogic>(
1697 mut shard: Shard<L>,
1698 wake: Arc<Notify>,
1699 mut mailbox: mpsc::Receiver<ShardCommand>,
1700) -> Shard<L> {
1701 let mut dirty = false;
1702 let mut mailbox_open = true;
1703 let mut next_reconcile = Instant::now();
1704
1705 loop {
1706 if dirty && (!mailbox_open || Instant::now() >= next_reconcile) {
1707 mailbox_open = drain_commands(&mut shard, &mut mailbox);
1708 let _report = shard.reconcile();
1709 dirty = false;
1710 next_reconcile = Instant::now() + MIN_INTERVAL;
1711 if !mailbox_open {
1712 break;
1713 }
1714 continue;
1715 }
1716
1717 if !mailbox_open {
1718 break;
1719 }
1720
1721 if dirty {
1722 tokio::select! {
1723 biased;
1724 // Cancellation-safe: recreating Sleep with the same absolute
1725 // deadline neither loses nor extends the cooldown.
1726 () = tokio::time::sleep_until(next_reconcile) => {}
1727 // Cancellation-safe: `recv` consumes a command only when this
1728 // branch completes.
1729 command = mailbox.recv() => match command {
1730 Some(command) => shard.handle(command),
1731 None => mailbox_open = false,
1732 },
1733 // Cancellation-safe: a cancelled `Notified` returns its permit.
1734 // The flavor state is external, so the signal only keeps this
1735 // turn dirty.
1736 () = wake.notified() => {},
1737 }
1738 } else {
1739 tokio::select! {
1740 // Cancellation-safe: `recv` consumes a command only when this
1741 // branch completes.
1742 command = mailbox.recv() => match command {
1743 Some(command) => {
1744 shard.handle(command);
1745 dirty = true;
1746 }
1747 // Every handle is gone; nothing can ever wake this shard again.
1748 None => break,
1749 },
1750 // Cancellation-safe: a cancelled `Notified` returns its permit.
1751 () = wake.notified() => dirty = true,
1752 }
1753 }
1754 }
1755
1756 shard
1757}
1758
1759/// Absorb one bounded burst before publishing.
1760///
1761/// The bound guarantees that a producer which continuously refills the channel
1762/// cannot postpone reconciliation forever. Commands arriving after the batch
1763/// are still consumed during the publication cooldown.
1764fn drain_commands<L: ShardLogic>(
1765 shard: &mut Shard<L>,
1766 mailbox: &mut mpsc::Receiver<ShardCommand>,
1767) -> bool {
1768 for _ in 0..MAILBOX_DEPTH {
1769 match mailbox.try_recv() {
1770 Ok(command) => shard.handle(command),
1771 Err(TryRecvError::Empty) => return true,
1772 Err(TryRecvError::Disconnected) => return false,
1773 }
1774 }
1775 true
1776}
1777
1778/// The handle and the driver halves for one shard.
1779///
1780/// Split out so a test can drive [`Shard::handle`] and [`Shard::reconcile`] by
1781/// hand without a task at all, which is what keeps the whole control plane
1782/// testable without a clock.
1783#[must_use]
1784pub fn spawn_parts(shard: ShardId) -> (ShardHandle, Arc<Notify>, mpsc::Receiver<ShardCommand>) {
1785 let wake = Arc::new(Notify::new());
1786 let (commands, mailbox) = mpsc::channel(MAILBOX_DEPTH);
1787 (
1788 ShardHandle {
1789 shard,
1790 wake: Arc::clone(&wake),
1791 commands,
1792 },
1793 wake,
1794 mailbox,
1795 )
1796}