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}