Skip to main content

sipx_call/
counters.rs

1//! The signalling path's losses, read as one thing.
2//!
3//! `docs/specs/sip-transport.md` §12.1 requires every discard in the signalling path to be counted.
4//! §12.3 says which crates that path is, and why the atomics behind those counts are still two sets
5//! while the *reading* of them is one.
6//!
7//! # Why a joined reading rather than joined storage
8//!
9//! `sipx-transport` cannot depend on `sipx-call` — the dependency runs the other way and reversing
10//! it would put the dialog layer underneath the socket. So the counters themselves stay where the
11//! events happen: the transport's in `sipx_transport::Counters`, the dispatcher's in
12//! [`DispatchCounts`]. That much is forced.
13//!
14//! What was *not* forced, and what `X-54` is about, is that an operator had to know the crate
15//! boundary to ask. `Handle::counters` and `Calls::counts` were two snapshots that nothing outside
16//! each crate's own tests ever read, so M12's clause — every discard counted **and exportable next
17//! to a capture** — was two features that existed separately. [`SignallingCounts`] is the one
18//! reading, and `sipx --counters` (`crates/sipx-cli`) is the export beside `--capture`.
19//!
20//! # The join embeds, it does not recount
21//!
22//! [`SignallingCounts::transport`] is exactly what [`sipx_transport::Handle::counters`] returns,
23//! copied and not re-derived, for the reason `sipx_transport::Counters::shed` already states about
24//! itself: two tallies of one event eventually disagree, and then neither can be trusted. The same
25//! rule is why this type has no arithmetic of its own beyond [`SignallingCounts::any_loss`], which
26//! is a disjunction of the two halves' own answers rather than a third opinion.
27//!
28//! # Why `dispatch` is an `Option` and not a zeroed struct
29//!
30//! An endpoint with no dispatcher running has not dispatched nothing — it has not been asked. Those
31//! are different claims and a zero cannot tell them apart, which is the failure `X-18` deleted
32//! `DiscardCounts::adopted_late` over: a counter structurally stuck at zero tells an operator "this
33//! never happens", and that is worse than silence.
34
35use sipx_transport::{Counters, Handle};
36
37use crate::dispatch::{Calls, DispatchCounts};
38
39/// Every loss in the signalling path, from both crates that own one (§12.3).
40///
41/// Built by [`SignallingCounts::of`] for an endpoint alone, or [`SignallingCounts::with_dispatcher`]
42/// when a dispatcher is running on it. Both are plain snapshots taken at the moment they are asked
43/// for: no metrics library, no background aggregation, and nothing here reads a clock.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct SignallingCounts {
47    /// What the transport counted, embedded unaltered.
48    pub transport: Counters,
49    /// What the dispatcher counted, or `None` when no dispatcher is running on this endpoint.
50    pub dispatch: Option<DispatchCounts>,
51}
52
53impl SignallingCounts {
54    /// The losses an endpoint alone can report.
55    ///
56    /// [`SignallingCounts::dispatch`] is `None`: no dispatcher has been named, so nothing is claimed
57    /// about the dialog layer rather than zero being claimed about it.
58    #[must_use]
59    pub fn of(endpoint: &Handle) -> Self {
60        Self {
61            transport: endpoint.counters(),
62            dispatch: None,
63        }
64    }
65
66    /// The losses an endpoint and the dispatcher running on it report together.
67    ///
68    /// The two halves are read one after the other and not under a shared lock, so a message being
69    /// dispatched as this is called can be counted by one half and not yet the other. That is the
70    /// same skew §12.2 already states for the transport's own counters, and it is the honest
71    /// trade: a lock spanning both would put the dialog layer's mutex in the socket's path.
72    #[must_use]
73    pub fn with_dispatcher(endpoint: &Handle, calls: &Calls) -> Self {
74        Self {
75            transport: endpoint.counters(),
76            dispatch: Some(calls.counts()),
77        }
78    }
79
80    /// Whether anything in the signalling path has been thrown away.
81    ///
82    /// A disjunction of the two halves' own answers, never a third tally. A `None` dispatcher
83    /// contributes nothing, because an unasked question is not a negative answer.
84    #[must_use]
85    pub fn any_loss(&self) -> bool {
86        self.transport.any_loss() || self.dispatch.is_some_and(|dispatch| dispatch.total() > 0)
87    }
88}