Skip to main content

lightstreamer_rs/client/
events.rs

1//! What the client tells you about the session itself.
2//!
3//! Everything here answers one question an application holding derived state
4//! has to be able to answer: **is what I computed still valid?** TLCP
5//! distinguishes an interruption the session survived from one that replaced
6//! it, and a client that hid the difference would force every application
7//! either to rebuild its state on every hiccup or to be silently wrong after a
8//! real one (`docs/adr/0005-recovery-is-visible-in-the-event-stream.md`).
9//!
10//! The answers a caller must be able to tell apart are:
11//!
12//! | You see | Your derived state is |
13//! |---|---|
14//! | [`SessionEvent::Connected`] with [`Continuity::Preserved`] | still valid |
15//! | [`SessionEvent::Connected`] with [`Continuity::Recovered`] | not decided yet — [`SessionEvent::Recovered`] follows and settles it |
16//! | [`SessionEvent::Connected`] with [`Continuity::New`] or [`Continuity::Replaced`] | invalid — discard and rebuild |
17//! | [`SessionEvent::Closed`] | invalid, and nothing more is coming |
18//!
19//! [`Continuity::state_validity`] is that table as a method, and it has three
20//! values rather than two because the middle row is a real state and not a
21//! rounding error.
22
23use std::time::Duration;
24
25use crate::client::ClientId;
26use crate::client::message::MessageOutcome;
27use crate::error::{Error, ServerError};
28use crate::session::{
29    Bandwidth, BindKind, BoundInfo, RecoveryKind, RecoveryOutcome as WireRecovery,
30    ResubscribedEntry, ServerAnnouncement, SessionClosed as WireClosed, SubscriptionKey,
31    UnbindReason,
32};
33
34/// An opaque handle identifying one subscription for as long as you hold it.
35///
36/// It survives everything the session does — a rebind, a recovery, and even a
37/// replacement of the session by a new one. That is deliberate: the protocol's
38/// own subscription numbers are only unique *within* a session and restart at
39/// one when a session is replaced [`docs/spec/02-session-lifecycle.md` §4.4],
40/// so a caller given those numbers would see its subscriptions change identity
41/// for reasons that have nothing to do with them.
42///
43/// # It names its client too
44///
45/// Every client numbers its own subscriptions from one, so the bare number is
46/// only unique within one client. The handle therefore carries the identity of
47/// the client that created it, and
48/// [`Client::unsubscribe`](crate::Client::unsubscribe) refuses one that came
49/// from a different client with [`Error::ForeignSubscription`]. Two clients in
50/// one process inevitably hand out the same *numbers*; without this they would
51/// hand out interchangeable *handles*, and cancelling the wrong data would be
52/// a plain type-checked call away.
53///
54/// Get one from [`Updates::id`](crate::Updates::id).
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub struct SubscriptionId {
57    /// Which client created it.
58    client: ClientId,
59    /// Which of that client's subscriptions it names.
60    key: SubscriptionKey,
61}
62
63impl SubscriptionId {
64    /// An opaque number, for logging and for use as a map key.
65    ///
66    /// Unique **within one client**: two clients each number their
67    /// subscriptions from one, so this alone does not identify a subscription
68    /// in a process that has more than one client. It is not the protocol's
69    /// `LS_subId` and must not be sent anywhere.
70    #[must_use]
71    #[inline]
72    pub const fn get(self) -> u64 {
73        self.key.get()
74    }
75
76    /// Pairs the session layer's key with the client that owns it.
77    pub(crate) const fn new(client: ClientId, key: SubscriptionKey) -> Self {
78        Self { client, key }
79    }
80
81    /// The session layer's key.
82    pub(crate) const fn key(self) -> SubscriptionKey {
83        self.key
84    }
85
86    /// Which client created this handle.
87    pub(crate) const fn client(self) -> ClientId {
88        self.client
89    }
90
91    /// An id with a chosen value, for [`crate::test_util`].
92    ///
93    /// Belongs to no real client, which is why it is only reachable from the
94    /// test-only surface: it can name a subscription in an assertion but
95    /// cannot cancel one.
96    #[cfg(feature = "test-util")]
97    #[must_use]
98    pub(crate) const fn from_raw(id: u64) -> Self {
99        Self {
100            client: ClientId::DETACHED,
101            key: SubscriptionKey::from_raw(id),
102        }
103    }
104}
105
106impl std::fmt::Display for SubscriptionId {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(
109            f,
110            "client#{}/subscription#{}",
111            self.client.get(),
112            self.key.get()
113        )
114    }
115}
116
117/// What a newly bound session means for state you built on the previous one.
118///
119/// This is the distinction ADR-0005 exists for. Read it as: *may I keep what I
120/// computed?* — and [`Continuity::state_validity`] is that question asked
121/// directly.
122///
123/// `Debug` redacts the previous session identifier for the reason given on
124/// [`Connected`]; the field itself is public and unredacted.
125#[derive(Clone, PartialEq, Eq)]
126#[non_exhaustive]
127pub enum Continuity {
128    /// The first session of this client.
129    ///
130    /// There is nothing this client preserved, which is not the same as
131    /// nothing being wrong: an application whose state outlived an earlier
132    /// client — rebuilt after [`Error::ReconnectExhausted`], say — is holding
133    /// state derived from a session that no longer exists. That is why
134    /// [`Continuity::state_validity`] answers
135    /// [`Invalid`](StateValidity::Invalid) here as it does for
136    /// [`Replaced`](Continuity::Replaced).
137    New,
138
139    /// The **same** session, rebound over a fresh connection after a clean
140    /// handover — the server told the client to reconnect and guaranteed that
141    /// nothing was dropped in between
142    /// [`docs/spec/02-session-lifecycle.md` §4.4].
143    ///
144    /// Every subscription is intact, server-side, with all its items and
145    /// fields. Keep your state.
146    Preserved,
147
148    /// The **same** session, resumed after an interruption the client
149    /// recovered from.
150    ///
151    /// Whether anything was actually missed is **not known yet** at this
152    /// point: the server answers separately, and this client reports the
153    /// answer as [`SessionEvent::Recovered`] immediately afterwards. Hold your
154    /// state and decide when [`Recovery::is_lossless`] says; this is the case
155    /// [`Continuity::state_validity`] reports as
156    /// [`Pending`](StateValidity::Pending), rather than claiming a
157    /// preservation the server has not confirmed.
158    Recovered {
159        /// The point in the session's notification count the client asked to
160        /// resume from.
161        requested_from: u64,
162    },
163
164    /// A **new** session replaced one that was lost.
165    ///
166    /// Nothing carries over: the notification count restarts at zero and every
167    /// subscription is created again from scratch
168    /// [`docs/spec/02-session-lifecycle.md` §6.1]. Any state you derived from
169    /// the previous session is stale — discard it and rebuild from the
170    /// snapshots that follow.
171    Replaced {
172        /// The identifier of the session this one replaces, when there was one
173        /// and the client knew it.
174        previous_session_id: Option<String>,
175    },
176}
177
178impl std::fmt::Debug for Continuity {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        match self {
181            Self::New => f.write_str("New"),
182            Self::Preserved => f.write_str("Preserved"),
183            Self::Recovered { requested_from } => f
184                .debug_struct("Recovered")
185                .field("requested_from", requested_from)
186                .finish(),
187            Self::Replaced {
188                previous_session_id,
189            } => f
190                .debug_struct("Replaced")
191                .field(
192                    "previous_session_id",
193                    &previous_session_id.as_ref().map(|_| crate::REDACTED),
194                )
195                .finish(),
196        }
197    }
198}
199
200/// What a bind means for state an application derived from an earlier one.
201///
202/// The answer to "may I keep what I computed?" has **three** values, not two,
203/// and a client that offered a boolean would have to guess one of them. See
204/// [`Continuity::state_validity`].
205#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
206#[non_exhaustive]
207pub enum StateValidity {
208    /// Keep it. The same session came back with everything intact.
209    Valid,
210
211    /// Not known yet. A recovery is in flight, and whether anything was lost
212    /// is something only the server can say — it does, in the
213    /// [`SessionEvent::Recovered`] that follows immediately. Hold your state
214    /// and decide when [`Recovery::is_lossless`] answers.
215    Pending,
216
217    /// Discard it. Nothing carries over, and the snapshots that follow are
218    /// what to rebuild from.
219    Invalid,
220}
221
222impl Continuity {
223    /// Whether state derived from an earlier session may still be used.
224    ///
225    /// This replaces a boolean that could not tell the truth. Two of the four
226    /// cases do not answer yes or no:
227    ///
228    /// | Continuity | Validity | Why |
229    /// |---|---|---|
230    /// | [`Preserved`](Continuity::Preserved) | [`Valid`](StateValidity::Valid) | the same session, nothing dropped |
231    /// | [`Recovered`](Continuity::Recovered) | [`Pending`](StateValidity::Pending) | the same session, but whether a gap opened is not known until the next event |
232    /// | [`Replaced`](Continuity::Replaced) | [`Invalid`](StateValidity::Invalid) | a different session; the notification count restarts |
233    /// | [`New`](Continuity::New) | [`Invalid`](StateValidity::Invalid) | there is no earlier session of *this client* to have preserved anything |
234    ///
235    /// `New` answering `Invalid` is the case worth explaining. It is not that
236    /// something was lost — nothing was — but that nothing was **kept**, and
237    /// an application whose state outlives its client (rebuilt after
238    /// [`Error::ReconnectExhausted`], say) is asking the same question and
239    /// deserves the same answer. Reporting a first session as "preserved"
240    /// would tell it to keep a book built on a session that no longer exists.
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use lightstreamer_rs::{Continuity, StateValidity};
246    ///
247    /// let recovering = Continuity::Recovered { requested_from: 42 };
248    /// assert_eq!(recovering.state_validity(), StateValidity::Pending);
249    /// assert_eq!(Continuity::New.state_validity(), StateValidity::Invalid);
250    /// ```
251    #[must_use]
252    #[inline]
253    pub const fn state_validity(&self) -> StateValidity {
254        match self {
255            Self::Preserved => StateValidity::Valid,
256            Self::Recovered { .. } => StateValidity::Pending,
257            Self::New | Self::Replaced { .. } => StateValidity::Invalid,
258        }
259    }
260}
261
262impl From<BindKind> for Continuity {
263    fn from(kind: BindKind) -> Self {
264        match kind {
265            BindKind::Created => Self::New,
266            BindKind::Rebound => Self::Preserved,
267            BindKind::Recovering {
268                requested_progressive,
269            } => Self::Recovered {
270                requested_from: requested_progressive,
271            },
272            BindKind::Recreated { previous } => Self::Replaced {
273                previous_session_id: previous.map(|id| id.as_str().to_owned()),
274            },
275        }
276    }
277}
278
279/// The session is bound to a server and streaming.
280///
281/// # `Debug` redacts the session identifier
282///
283/// The identifier is a **bearer value**: `LS_session` is what a control
284/// request or a rebind names the session with
285/// [`docs/spec/03-requests.md` §5.1], so anything holding it can act on the
286/// session. This crate therefore hands it to you — it is a public field, and
287/// correlating it with a server-side log is exactly what it is for — but never
288/// puts it in a log line or a `{:?}` of its own accord. Print
289/// [`Connected::session_id`] yourself when you want it somewhere.
290#[derive(Clone, PartialEq, Eq)]
291#[non_exhaustive]
292pub struct Connected {
293    /// The server's identifier for this session. Opaque, and treated as
294    /// sensitive: see the type's own documentation before logging it.
295    pub session_id: String,
296    /// What this bind means for state derived from an earlier one.
297    pub continuity: Continuity,
298    /// The longest the server will let the connection go quiet before sending
299    /// something, even if there is no data
300    /// [`docs/spec/02-session-lifecycle.md` §8.1].
301    ///
302    /// This is the value the server chose, not the one you asked for, and it
303    /// is what this client's own liveness check is built from.
304    pub keepalive: Duration,
305    /// The largest request, in bytes, the server will accept on this session
306    /// [`docs/spec/02-session-lifecycle.md` §3.1].
307    pub request_limit_bytes: u64,
308}
309
310#[cfg(feature = "test-util")]
311impl Connected {
312    /// Assembles one field by field, for [`crate::test_util`].
313    #[must_use]
314    pub(crate) const fn from_parts(
315        session_id: String,
316        continuity: Continuity,
317        keepalive: Duration,
318        request_limit_bytes: u64,
319    ) -> Self {
320        Self {
321            session_id,
322            continuity,
323            keepalive,
324            request_limit_bytes,
325        }
326    }
327}
328
329impl std::fmt::Debug for Connected {
330    /// Everything but the session identifier, which is redacted for the reason
331    /// on the type.
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        f.debug_struct("Connected")
334            .field("session_id", &crate::REDACTED)
335            .field("continuity", &self.continuity)
336            .field("keepalive", &self.keepalive)
337            .field("request_limit_bytes", &self.request_limit_bytes)
338            .finish()
339    }
340}
341
342impl From<BoundInfo> for Connected {
343    fn from(info: BoundInfo) -> Self {
344        Self {
345            session_id: info.session_id.as_str().to_owned(),
346            continuity: info.kind.into(),
347            keepalive: info.keep_alive,
348            request_limit_bytes: info.request_limit_bytes,
349        }
350    }
351}
352
353/// Where a recovered flow actually resumed, relative to where it was asked to.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355#[non_exhaustive]
356pub struct Recovery {
357    /// The point the client asked to resume from.
358    pub requested_from: u64,
359    /// The point the server actually resumed from.
360    pub resumed_at: u64,
361    /// What the difference between the two means.
362    pub outcome: RecoveryOutcome,
363}
364
365/// The three ways a recovery can turn out.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367#[non_exhaustive]
368pub enum RecoveryOutcome {
369    /// The server resumed exactly where the client asked. Nothing was lost and
370    /// nothing was duplicated: continuity is intact.
371    Exact,
372
373    /// The server resumed **earlier** than asked, which it is allowed to do,
374    /// and re-sent notifications the client already had. This client discarded
375    /// them before you saw them, so continuity is still intact
376    /// [`docs/spec/02-session-lifecycle.md` §5.2].
377    Duplicated {
378        /// How many re-delivered notifications were suppressed.
379        suppressed: u64,
380    },
381
382    /// The server resumed **later** than asked: the notifications in between
383    /// are gone and will not arrive.
384    ///
385    /// State derived from the affected subscriptions may be stale. The
386    /// protocol does not say this can happen and does not say what a client
387    /// should do about it, so this crate reports it rather than deciding for
388    /// you [`docs/spec/02-session-lifecycle.md` §5.4].
389    Gap {
390        /// How many notifications were skipped.
391        missing: u64,
392    },
393}
394
395impl Recovery {
396    /// Whether nothing was lost.
397    #[must_use]
398    #[inline]
399    pub const fn is_lossless(&self) -> bool {
400        !matches!(self.outcome, RecoveryOutcome::Gap { .. })
401    }
402
403    /// Assembles one field by field, for [`crate::test_util`].
404    #[cfg(feature = "test-util")]
405    #[must_use]
406    pub(crate) const fn from_parts(
407        requested_from: u64,
408        resumed_at: u64,
409        outcome: RecoveryOutcome,
410    ) -> Self {
411        Self {
412            requested_from,
413            resumed_at,
414            outcome,
415        }
416    }
417}
418
419impl From<WireRecovery> for Recovery {
420    fn from(outcome: WireRecovery) -> Self {
421        Self {
422            requested_from: outcome.requested,
423            resumed_at: outcome.resumed_at,
424            outcome: match outcome.kind {
425                RecoveryKind::Exact => RecoveryOutcome::Exact,
426                RecoveryKind::Duplicated { count } => {
427                    RecoveryOutcome::Duplicated { suppressed: count }
428                }
429                RecoveryKind::Gap { missing } => RecoveryOutcome::Gap { missing },
430            },
431        }
432    }
433}
434
435/// One subscription re-created on a session that replaced a lost one.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437#[non_exhaustive]
438pub struct Resubscribed {
439    /// Which subscription — the same handle you have been holding all along.
440    pub id: SubscriptionId,
441    /// Whether the server had confirmed this subscription before the session
442    /// was lost. `false` means it was still pending and never became active.
443    pub was_active: bool,
444    /// Whether this subscription asked for a snapshot, and so restarts from a
445    /// complete picture rather than resuming mid-flow.
446    ///
447    /// If this is `false`, an application that discarded its state because of
448    /// a [`Continuity::Replaced`] has no way to rebuild it except by waiting
449    /// for every field to tick again. That is a property of the subscription,
450    /// not a failure.
451    pub snapshot_restarting: bool,
452}
453
454#[cfg(feature = "test-util")]
455impl Resubscribed {
456    /// Assembles one field by field, for [`crate::test_util`].
457    #[must_use]
458    pub(crate) const fn from_parts(
459        id: SubscriptionId,
460        was_active: bool,
461        snapshot_restarting: bool,
462    ) -> Self {
463        Self {
464            id,
465            was_active,
466            snapshot_restarting,
467        }
468    }
469}
470
471impl Resubscribed {
472    /// Names the client the re-created subscription belongs to.
473    ///
474    /// Not a [`From`] impl, because the session layer's entry carries only the
475    /// key: the owning client is known here and nowhere below.
476    pub(crate) const fn from_entry(client: ClientId, entry: ResubscribedEntry) -> Self {
477        Self {
478            id: SubscriptionId::new(client, entry.key),
479            was_active: entry.previously_active,
480            snapshot_restarting: entry.snapshot_requested,
481        }
482    }
483}
484
485/// Why the session stopped streaming.
486///
487/// A disconnection is not the end: this client reconnects on its own, and
488/// [`SessionEvent::Disconnected`] carries how long until the next attempt. It
489/// is the end only when [`SessionEvent::Closed`] follows.
490#[derive(Debug, Clone, PartialEq, Eq)]
491#[non_exhaustive]
492pub enum DisconnectReason {
493    /// The server handed the connection over cleanly and expects the client to
494    /// reconnect. The session itself is untouched and every subscription
495    /// survives [`docs/spec/02-session-lifecycle.md` §4.4].
496    ///
497    /// Routine: it is how TLCP rotates long-lived connections.
498    Handover,
499
500    /// The connection failed or was dropped. The session is not closed and a
501    /// recovery is attempted [`docs/spec/02-session-lifecycle.md` §2.2].
502    ConnectionFailed {
503        /// What the transport reported. Diagnostic text; never a credential.
504        detail: String,
505    },
506
507    /// The connection went silent for longer than the server promised, without
508    /// ever failing.
509    ///
510    /// This is the failure mode a protocol client exists to catch: a socket
511    /// that is wedged but never errors. The connection is torn down and
512    /// recovered [`docs/spec/02-session-lifecycle.md` §8.1].
513    Stalled {
514        /// The silence budget that elapsed with no traffic of any kind.
515        budget: Duration,
516    },
517
518    /// The server refused the session with a code that still allows another
519    /// attempt.
520    Refused(ServerError),
521
522    /// The server ended the session in order to refresh it, and expects a new
523    /// one to be opened immediately — Appendix A code `48`
524    /// [`docs/spec/05-error-codes.md` §2].
525    ///
526    /// Nothing is wrong, but the session that follows is a **new** one: expect
527    /// a [`Continuity::Replaced`] next.
528    Refreshing(ServerError),
529}
530
531impl From<UnbindReason> for DisconnectReason {
532    fn from(reason: UnbindReason) -> Self {
533        match reason {
534            UnbindReason::ForcedByClient { .. }
535            | UnbindReason::ContentLengthReached { .. }
536            | UnbindReason::PollCycleExpired { .. }
537            | UnbindReason::Looped { .. } => Self::Handover,
538            UnbindReason::ConnectionFailed { detail } => Self::ConnectionFailed { detail },
539            UnbindReason::KeepaliveExpired { budget } => Self::Stalled { budget },
540            UnbindReason::Rejected { cause } => Self::Refused(cause.into()),
541            UnbindReason::ServerRefresh { cause } => Self::Refreshing(cause.into()),
542        }
543    }
544}
545
546/// Why the session ended for good.
547///
548/// After this the client is inert: every operation returns
549/// [`Error::Disconnected`] and no further event arrives.
550#[derive(Debug, Clone, PartialEq, Eq)]
551#[non_exhaustive]
552pub enum ClosedReason {
553    /// You asked for it — [`Client::disconnect`](crate::Client::disconnect),
554    /// or the client was dropped.
555    ByClient,
556
557    /// The server ended the session, or refused to start one, with an
558    /// Appendix A code that admits no retry
559    /// [`docs/spec/05-error-codes.md` §2].
560    ByServer(ServerError),
561
562    /// The connection kept failing and the reconnection budget ran out.
563    ReconnectExhausted {
564        /// How many consecutive attempts failed.
565        attempts: u32,
566        /// Why the **last** attempt failed, when there was one to report.
567        ///
568        /// The count alone says a client gave up; it does not say whether the
569        /// server was refusing the credentials, the socket never opened, or
570        /// the connection kept going silent — which are three different
571        /// things to do about it. This is that answer, in the same vocabulary
572        /// [`SessionEvent::Disconnected`] uses.
573        last: Option<Box<DisconnectReason>>,
574    },
575
576    /// A failure inside this crate. A bug; please report it.
577    Internal {
578        /// What failed. Never contains a credential.
579        reason: String,
580    },
581}
582
583impl ClosedReason {
584    /// The same reason expressed as an [`Error`], for a caller that wants to
585    /// propagate it with `?`.
586    ///
587    /// # Examples
588    ///
589    /// ```
590    /// use lightstreamer_rs::{ClosedReason, Error};
591    ///
592    /// let reason = ClosedReason::ReconnectExhausted { attempts: 8, last: None };
593    /// assert!(matches!(reason.into_error(), Error::ReconnectExhausted { attempts: 8, .. }));
594    /// ```
595    #[must_use]
596    pub fn into_error(self) -> Error {
597        match self {
598            Self::ByClient => Error::Disconnected,
599            Self::ByServer(cause) => Error::Session(cause),
600            Self::ReconnectExhausted { attempts, last } => {
601                Error::ReconnectExhausted { attempts, last }
602            }
603            Self::Internal { reason } => Error::Internal { reason },
604        }
605    }
606}
607
608impl From<WireClosed> for ClosedReason {
609    fn from(closed: WireClosed) -> Self {
610        match closed {
611            WireClosed::ByClient { .. } => Self::ByClient,
612            WireClosed::ByServer { cause } => Self::ByServer(cause.into()),
613            // The last thing that went wrong is carried through rather than
614            // dropped: an attempt count with no cause tells a caller that
615            // something failed eight times and nothing about what.
616            WireClosed::RetriesExhausted { attempts, last } => Self::ReconnectExhausted {
617                attempts,
618                last: last.map(|reason| Box::new(DisconnectReason::from(reason))),
619            },
620            WireClosed::Internal { reason } => Self::Internal { reason },
621        }
622    }
623}
624
625/// Something the server said about itself or about the session, which changes
626/// nothing but may be worth logging.
627#[derive(Debug, Clone, PartialEq, Eq)]
628#[non_exhaustive]
629pub enum ServerInfo {
630    /// The configured name of the server port the session is bound to.
631    /// Diagnostic only [`docs/spec/04-notifications.md` §5.4].
632    ServerName(String),
633
634    /// The client's own address, as the server sees it. A change between two
635    /// binds means the client moved network
636    /// [`docs/spec/04-notifications.md` §5.3].
637    ClientIp(String),
638
639    /// The bandwidth limit now in force for the session
640    /// [`docs/spec/04-notifications.md` §5.1].
641    BandwidthLimit {
642        /// The limit in kbps, exactly as the server wrote it, or `None` when
643        /// the server declared no limit.
644        kbps: Option<String>,
645        /// Whether the client is forbidden to change the limit — the server's
646        /// `unmanaged` answer.
647        managed: bool,
648    },
649
650    /// **The bandwidth constraint could not be read**, so do not treat it as a
651    /// limit [`docs/spec/04-notifications.md` §5.1].
652    ///
653    /// The server sent something that is neither `unlimited`, nor `unmanaged`,
654    /// nor a decimal number. It is reported apart from
655    /// [`BandwidthLimit`](ServerInfo::BandwidthLimit) rather than as a limit of
656    /// `Some("")`, because a caller acting on a fabricated number is worse off
657    /// than one told the value was unreadable. What the session's real limit is
658    /// remains whatever the last readable `CONS` said.
659    UnreadableBandwidthLimit {
660        /// The value as it arrived.
661        literal: String,
662    },
663
664    /// How long the server thinks the session has been bound
665    /// [`docs/spec/04-notifications.md` §5.2].
666    ///
667    /// Comparing it with your own elapsed time detects a client falling behind
668    /// the update flow, or a machine that was suspended. This crate reports it
669    /// and acts on it in no way.
670    Clock {
671        /// Seconds elapsed on the server since the session was bound.
672        elapsed_seconds: u64,
673    },
674}
675
676impl From<ServerAnnouncement> for ServerInfo {
677    /// Translates what the session layer heard the server say about itself.
678    ///
679    /// Total: the session already decided which notifications are server info,
680    /// so there is no "not really one of these" case left to represent, and no
681    /// way for a notification that later becomes meaningful to be silently
682    /// mislabelled here.
683    fn from(announcement: ServerAnnouncement) -> Self {
684        match announcement {
685            ServerAnnouncement::Name(name) => Self::ServerName(name),
686            ServerAnnouncement::ClientIp(address) => Self::ClientIp(address),
687            ServerAnnouncement::Clock { elapsed_seconds } => Self::Clock { elapsed_seconds },
688            ServerAnnouncement::Bandwidth(bandwidth) => match bandwidth {
689                Bandwidth::Limited { kbps } => Self::BandwidthLimit {
690                    kbps: Some(kbps),
691                    managed: true,
692                },
693                Bandwidth::Unlimited => Self::BandwidthLimit {
694                    kbps: None,
695                    managed: true,
696                },
697                Bandwidth::Unmanaged => Self::BandwidthLimit {
698                    kbps: None,
699                    managed: false,
700                },
701                Bandwidth::Unrecognized { literal } => Self::UnreadableBandwidthLimit { literal },
702            },
703        }
704    }
705}
706
707/// Everything the client tells you about the session.
708///
709/// Delivered by [`SessionEvents`](crate::SessionEvents). The enum is closed
710/// but `#[non_exhaustive]`: match it exhaustively with a `_` arm, and a future
711/// version adding a variant will not break your build.
712///
713/// # Examples
714///
715/// ```no_run
716/// use futures_util::StreamExt;
717/// use lightstreamer_rs::{SessionEvent, StateValidity};
718///
719/// # async fn run(mut events: lightstreamer_rs::SessionEvents) {
720/// while let Some(event) = events.next().await {
721///     match event {
722///         SessionEvent::Connected(connected) => match connected.continuity.state_validity() {
723///             // Keep what you computed: the same session came back intact.
724///             StateValidity::Valid => {}
725///             // A recovery is in flight; the next event says whether it lost
726///             // anything. Decide then.
727///             StateValidity::Pending => {}
728///             // Nothing carries over: rebuild from the snapshots that follow.
729///             StateValidity::Invalid => {}
730///             _ => {}
731///         },
732///         SessionEvent::Closed(reason) => {
733///             tracing::error!(?reason, "session is over");
734///             break;
735///         }
736///         _ => {}
737///     }
738/// }
739/// # }
740/// ```
741#[derive(Debug, Clone, PartialEq, Eq)]
742#[non_exhaustive]
743pub enum SessionEvent {
744    /// The session is bound to a server and streaming. Read
745    /// [`Connected::continuity`] to learn whether your derived state survived.
746    Connected(Box<Connected>),
747
748    /// A recovery reported where the flow resumed. Always follows a
749    /// [`SessionEvent::Connected`] carrying [`Continuity::Recovered`].
750    Recovered(Recovery),
751
752    /// Subscriptions were re-created on a newly bound session.
753    ///
754    /// Emitted whenever any subscription had to be issued at bind time, which
755    /// after a session replacement is all of them. Your
756    /// [`Updates`](crate::Updates) streams keep working across this; the event
757    /// exists so you know a fresh snapshot is on its way.
758    Resubscribed(Vec<Resubscribed>),
759
760    /// The session stopped streaming. A reconnection is already scheduled
761    /// unless `retry_in` is `None`, which means the client is about to give up
762    /// and a [`SessionEvent::Closed`] follows.
763    Disconnected {
764        /// Why it stopped.
765        reason: DisconnectReason,
766        /// How long until the next attempt, or `None` when there will not be
767        /// one.
768        retry_in: Option<Duration>,
769    },
770
771    /// What became of a message you sent with
772    /// [`Client::send_message`](crate::Client::send_message)
773    /// [`docs/spec/03-requests.md` §12].
774    ///
775    /// Only ever arrives for a message that carried a progressive; a
776    /// fire-and-forget one is reported here only if it never left this client
777    /// or the server refused the request outright.
778    Message(Box<MessageOutcome>),
779
780    /// The server said something diagnostic.
781    ServerInfo(ServerInfo),
782
783    /// The server refused a control request, with a code from **Appendix B**
784    /// [`docs/spec/05-error-codes.md` §2]. The session is unaffected.
785    ///
786    /// A refused **subscription request** is reported on that subscription's
787    /// own stream instead, as
788    /// [`SubscriptionEvent::Rejected`](crate::SubscriptionEvent::Rejected),
789    /// which is where you are looking for it. What lands here is everything
790    /// else:
791    ///
792    /// - a refused **unsubscription** or **reconfiguration** — note that in
793    ///   both cases the subscription is **still active**; it is your request
794    ///   to change it that failed, not the subscription that died, and its
795    ///   stream keeps delivering;
796    /// - a refused request that belongs to the session as a whole;
797    /// - a refusal the server sent with no identifier at all, which the
798    ///   protocol allows when it could not parse the request
799    ///   [`docs/spec/03-requests.md` §13.2].
800    ///
801    /// Every [`ServerError`] carried here is something a server actually said.
802    /// A request that failed before reaching one is
803    /// [`SessionEvent::RequestNotSent`] instead.
804    RequestRejected(ServerError),
805
806    /// A control request never reached the server, so no server refused it.
807    ///
808    /// This is a **local** failure — the connection was down, or the request
809    /// could not be encoded — and it is deliberately not a [`ServerError`].
810    /// Giving it a fabricated code would be indistinguishable from a code the
811    /// server's Metadata Adapter supplied
812    /// (see [`ServerError::is_adapter_defined`]), which is a claim this crate
813    /// is in no position to make.
814    ///
815    /// Subscription requests are exempt: an `add` that never left is reported
816    /// on the subscription's own stream as
817    /// [`SubscriptionEvent::Deferred`](crate::SubscriptionEvent::Deferred),
818    /// because it is still coming.
819    RequestNotSent {
820        /// Why the request could not be sent.
821        reason: String,
822    },
823
824    /// A line arrived that this client could not parse.
825    ///
826    /// Never fatal, and deliberately surfaced rather than swallowed: a server
827    /// newer than this crate must not be able to break it, and you should be
828    /// able to see that it happened.
829    Unrecognized {
830        /// The line exactly as it arrived, with its terminator stripped.
831        line: String,
832    },
833
834    /// Terminal. The session is over and no further event will arrive.
835    Closed(ClosedReason),
836}
837
838/// The stream of session-level events.
839///
840/// Handed back by [`Client::connect`](crate::Client::connect), alongside the
841/// client itself, because a caller that never reads it should have to decide
842/// that on purpose rather than by omission. It implements
843/// [`futures_util::Stream`].
844///
845/// # Dropping this stream
846///
847/// Dropping it means "I do not want session events". The client keeps running
848/// exactly as before — reconnection, recovery and resubscription all still
849/// happen — you simply stop being told about them. Nothing blocks and nothing
850/// leaks.
851///
852/// # Backpressure: bounded, and nothing is dropped
853///
854/// The stream is fed by a **bounded** channel of
855/// [`ConnectionOptions::with_session_event_capacity`](crate::ConnectionOptions::with_session_event_capacity)
856/// events — 256 by default. While you hold it, the client **blocks** rather
857/// than discarding an event when the buffer is full, and that backpressure
858/// reaches the socket. A held-but-unread stream will therefore stall the
859/// client once it fills.
860///
861/// The reasoning is the same as for [`Updates`](crate::Updates): these events
862/// are what tell an application whether its derived state survived, so
863/// silently dropping one would be worse than making a slow consumer visible.
864/// If you do not intend to read them, drop the stream — that is the supported
865/// way to opt out.
866///
867/// A stall longer than the session's keepalive interval provokes a
868/// reconnection, for the reason set out under
869/// [`Updates`](crate::Updates#what-a-long-stall-actually-does). Holding this
870/// stream and not reading it is the one shape to avoid.
871///
872/// As with [`Updates`](crate::Updates#the-one-exception-shutdown), losslessness
873/// holds while the client is running and yields to an ordered shutdown: a
874/// stream nobody is reading cannot keep a client that has been asked to stop
875/// alive. Anything not yet delivered when that happens goes with the stream.
876///
877/// # Events from before this stream existed
878///
879/// [`Client::connect`](crate::Client::connect) hands this over once the session
880/// is bound, so nobody can hold it while the connection attempts that preceded
881/// the bind are happening. Those events are not lost: they are replayed here
882/// first, in the order they occurred. Past the configured capacity they are
883/// discarded with a warning instead — there is no consumer for them yet, and
884/// waiting for one would deadlock the very bind that produces this stream.
885///
886/// # Examples
887///
888/// ```no_run
889/// # use lightstreamer_rs::{Client, ClientConfig, ServerAddress};
890/// # async fn run(config: ClientConfig) -> lightstreamer_rs::Result<()> {
891/// let (client, events) = Client::connect(config).await?;
892/// // Not interested: opt out explicitly, and nothing ever blocks on it.
893/// drop(events);
894/// # Ok(())
895/// # }
896/// ```
897#[derive(Debug)]
898pub struct SessionEvents {
899    /// Events produced before [`Client::connect`](crate::Client::connect)
900    /// returned, in arrival order. Nobody could hold this stream while they
901    /// happened, so `connect` drained them out of the channel — otherwise a
902    /// retried connection could fill it and block the very task that has to
903    /// deliver the bind — and they are replayed here, ahead of the channel, so
904    /// the order the caller sees is the order they happened in.
905    staged: std::collections::VecDeque<SessionEvent>,
906    events: tokio::sync::mpsc::Receiver<SessionEvent>,
907}
908
909impl SessionEvents {
910    /// Wraps the receiving half the router publishes to, together with the
911    /// events that arrived before the caller could hold the stream.
912    pub(crate) const fn with_staged(
913        staged: std::collections::VecDeque<SessionEvent>,
914        events: tokio::sync::mpsc::Receiver<SessionEvent>,
915    ) -> Self {
916        Self { staged, events }
917    }
918}
919
920impl futures_util::Stream for SessionEvents {
921    type Item = SessionEvent;
922
923    fn poll_next(
924        mut self: std::pin::Pin<&mut Self>,
925        cx: &mut std::task::Context<'_>,
926    ) -> std::task::Poll<Option<Self::Item>> {
927        if let Some(event) = self.staged.pop_front() {
928            return std::task::Poll::Ready(Some(event));
929        }
930        self.events.poll_recv(cx)
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use crate::session::{ServerCause, SessionId};
938
939    #[test]
940    fn test_continuity_distinguishes_the_three_adr_0005_cases() {
941        // Continuity preserved.
942        assert!(matches!(
943            Continuity::from(BindKind::Rebound),
944            Continuity::Preserved
945        ));
946        assert!(matches!(
947            Continuity::from(BindKind::Recovering {
948                requested_progressive: 42
949            }),
950            Continuity::Recovered { .. }
951        ));
952        // Session re-established.
953        let replaced = Continuity::from(BindKind::Recreated {
954            previous: Some(SessionId::new("S1")),
955        });
956        assert!(matches!(
957            replaced,
958            Continuity::Replaced { previous_session_id } if previous_session_id.as_deref() == Some("S1")
959        ));
960        // First session of all.
961        assert!(matches!(
962            Continuity::from(BindKind::Created),
963            Continuity::New
964        ));
965    }
966
967    // -----------------------------------------------------------------------
968    // A-06: the answer to "may I keep my state" has three values
969    // -----------------------------------------------------------------------
970
971    #[test]
972    fn test_only_a_clean_rebind_reports_state_as_valid() {
973        assert_eq!(Continuity::Preserved.state_validity(), StateValidity::Valid);
974    }
975
976    #[test]
977    fn test_a_recovery_in_flight_reports_state_as_pending() {
978        // The gap, if there is one, is reported by the *next* event. Claiming
979        // preservation here would be claiming to know something the server has
980        // not said yet [`docs/spec/02-session-lifecycle.md` §5.2].
981        assert_eq!(
982            Continuity::Recovered { requested_from: 42 }.state_validity(),
983            StateValidity::Pending
984        );
985    }
986
987    #[test]
988    fn test_a_first_session_reports_state_as_invalid_like_a_replacement() {
989        // Nothing was lost, but nothing was *kept* either — and an application
990        // whose state outlived a previous client is asking exactly the
991        // question a first session cannot answer yes to.
992        assert_eq!(Continuity::New.state_validity(), StateValidity::Invalid);
993        assert_eq!(
994            Continuity::Replaced {
995                previous_session_id: Some("S1".to_owned())
996            }
997            .state_validity(),
998            StateValidity::Invalid
999        );
1000    }
1001
1002    #[test]
1003    fn test_a_recovery_that_reports_a_gap_resolves_the_pending_answer() {
1004        // The pair a caller is meant to read together: `Pending`, then the
1005        // recovery outcome that settles it.
1006        let bind = Continuity::from(BindKind::Recovering {
1007            requested_progressive: 10,
1008        });
1009        assert_eq!(bind.state_validity(), StateValidity::Pending);
1010
1011        let lossless = Recovery::from(WireRecovery {
1012            requested: 10,
1013            resumed_at: 10,
1014            kind: RecoveryKind::Exact,
1015        });
1016        assert!(lossless.is_lossless());
1017
1018        let lossy = Recovery::from(WireRecovery {
1019            requested: 10,
1020            resumed_at: 14,
1021            kind: RecoveryKind::Gap { missing: 4 },
1022        });
1023        assert!(!lossy.is_lossless());
1024    }
1025
1026    // -----------------------------------------------------------------------
1027    // A-01: no identifier of a session reaches a `Debug`
1028    // -----------------------------------------------------------------------
1029
1030    #[test]
1031    fn test_debug_redacts_every_session_identifier() {
1032        let connected = Connected {
1033            session_id: "S1234abcd".to_owned(),
1034            continuity: Continuity::Replaced {
1035                previous_session_id: Some("S5678efgh".to_owned()),
1036            },
1037            keepalive: Duration::from_secs(5),
1038            request_limit_bytes: 50_000,
1039        };
1040        let rendered = format!("{connected:?}");
1041        assert!(!rendered.contains("S1234abcd"), "{rendered}");
1042        assert!(!rendered.contains("S5678efgh"), "{rendered}");
1043        assert!(rendered.contains(crate::REDACTED), "{rendered}");
1044        // The diagnostic value is kept: what the bind negotiated is still there.
1045        assert!(rendered.contains("50000"), "{rendered}");
1046
1047        // And through the event that carries it, which is how a caller's
1048        // `tracing::debug!(?event)` would reach it.
1049        let event = SessionEvent::Connected(Box::new(connected));
1050        assert!(!format!("{event:?}").contains("S1234abcd"));
1051    }
1052
1053    #[test]
1054    fn test_continuity_carries_the_requested_recovery_point() {
1055        assert!(matches!(
1056            Continuity::from(BindKind::Recovering {
1057                requested_progressive: 17
1058            }),
1059            Continuity::Recovered { requested_from: 17 }
1060        ));
1061    }
1062
1063    #[test]
1064    fn test_recovery_reports_a_gap_as_lossy() {
1065        let exact = Recovery::from(WireRecovery {
1066            requested: 10,
1067            resumed_at: 10,
1068            kind: RecoveryKind::Exact,
1069        });
1070        assert!(exact.is_lossless());
1071
1072        let duplicated = Recovery::from(WireRecovery {
1073            requested: 10,
1074            resumed_at: 7,
1075            kind: RecoveryKind::Duplicated { count: 3 },
1076        });
1077        assert!(duplicated.is_lossless());
1078        assert!(matches!(
1079            duplicated.outcome,
1080            RecoveryOutcome::Duplicated { suppressed: 3 }
1081        ));
1082
1083        let gap = Recovery::from(WireRecovery {
1084            requested: 10,
1085            resumed_at: 14,
1086            kind: RecoveryKind::Gap { missing: 4 },
1087        });
1088        assert!(!gap.is_lossless());
1089    }
1090
1091    #[test]
1092    fn test_disconnect_reason_maps_a_clean_handover_apart_from_a_failure() {
1093        assert!(matches!(
1094            DisconnectReason::from(UnbindReason::ContentLengthReached {
1095                expected_delay: Duration::ZERO
1096            }),
1097            DisconnectReason::Handover
1098        ));
1099        assert!(matches!(
1100            DisconnectReason::from(UnbindReason::KeepaliveExpired {
1101                budget: Duration::from_secs(8)
1102            }),
1103            DisconnectReason::Stalled { .. }
1104        ));
1105        assert!(matches!(
1106            DisconnectReason::from(UnbindReason::ServerRefresh {
1107                cause: ServerCause {
1108                    code: 48,
1109                    message: String::new()
1110                }
1111            }),
1112            DisconnectReason::Refreshing(cause) if cause.code() == 48
1113        ));
1114    }
1115
1116    #[test]
1117    fn test_closed_reason_round_trips_into_an_error() {
1118        assert!(matches!(
1119            ClosedReason::ByClient.into_error(),
1120            Error::Disconnected
1121        ));
1122        assert!(matches!(
1123            ClosedReason::ByServer(ServerError::new(2, "no adapter set")).into_error(),
1124            Error::Session(cause) if cause.code() == 2
1125        ));
1126        assert!(matches!(
1127            ClosedReason::Internal {
1128                reason: "boom".to_owned()
1129            }
1130            .into_error(),
1131            Error::Internal { .. }
1132        ));
1133    }
1134
1135    #[test]
1136    fn test_server_info_translates_what_the_session_announced() {
1137        assert!(matches!(
1138            ServerInfo::from(ServerAnnouncement::Name(
1139                "Lightstreamer HTTP Server".to_owned()
1140            )),
1141            ServerInfo::ServerName(name) if name.contains("Lightstreamer")
1142        ));
1143        assert!(matches!(
1144            ServerInfo::from(ServerAnnouncement::Clock {
1145                elapsed_seconds: 12
1146            }),
1147            ServerInfo::Clock {
1148                elapsed_seconds: 12
1149            }
1150        ));
1151        assert!(matches!(
1152            ServerInfo::from(ServerAnnouncement::ClientIp("0:0:0:0:0:0:0:1".to_owned())),
1153            ServerInfo::ClientIp(address) if address == "0:0:0:0:0:0:0:1"
1154        ));
1155        assert!(matches!(
1156            ServerInfo::from(ServerAnnouncement::Bandwidth(Bandwidth::Unmanaged)),
1157            ServerInfo::BandwidthLimit {
1158                kbps: None,
1159                managed: false
1160            }
1161        ));
1162        assert!(matches!(
1163            ServerInfo::from(ServerAnnouncement::Bandwidth(Bandwidth::Unlimited)),
1164            ServerInfo::BandwidthLimit {
1165                kbps: None,
1166                managed: true
1167            }
1168        ));
1169        // A bandwidth the parser could not read is reported as unreadable, not
1170        // as a limit of `Some("")` [`docs/spec/04-notifications.md` §5.1].
1171        assert!(matches!(
1172            ServerInfo::from(ServerAnnouncement::Bandwidth(Bandwidth::Unrecognized {
1173                literal: String::new(),
1174            })),
1175            ServerInfo::UnreadableBandwidthLimit { literal } if literal.is_empty()
1176        ));
1177    }
1178
1179    #[test]
1180    fn test_unrecognized_lines_are_surfaced_verbatim() {
1181        // "Unknown is not fatal": the raw line reaches the caller unchanged.
1182        let event = SessionEvent::Unrecognized {
1183            line: "FUTURE,1,2,3".to_owned(),
1184        };
1185        assert!(matches!(event, SessionEvent::Unrecognized { line } if line == "FUTURE,1,2,3"));
1186    }
1187}