Skip to main content

signal_fish_client/
event.rs

1//! High-level events emitted by the Signal Fish client.
2//!
3//! [`SignalFishEvent`] provides a 1:1 mapping from every [`ServerMessage`] variant
4//! plus three synthetic events (`Connected`, `Disconnected`, and `DecodeFailed`)
5//! that originate from the transport layer rather than the server.
6//!
7//! Boxed payload types ([`RoomJoinedPayload`], [`ReconnectedPayload`],
8//! [`SpectatorJoinedPayload`]) are flattened into inline fields so callers can
9//! pattern-match directly without an extra dereference.
10//!
11//! [`ServerMessage`]: crate::protocol::ServerMessage
12//! [`RoomJoinedPayload`]: crate::protocol::RoomJoinedPayload
13//! [`ReconnectedPayload`]: crate::protocol::ReconnectedPayload
14//! [`SpectatorJoinedPayload`]: crate::protocol::SpectatorJoinedPayload
15
16use crate::error_codes::ErrorCode;
17use crate::protocol::{
18    DeliveryClass, DeliveryReportPayload, GameDataEncoding, IceServer, LobbyState,
19    PeerConnectionInfo, PlayerId, PlayerInfo, ProtocolInfoPayload, RateLimitInfo, ReplayStatus,
20    RoomId, SenderWatermark, ServerMessage, SessionPeer, SpectatorInfo, SpectatorStateChangeReason,
21    Topology, TransportKind,
22};
23
24/// Events emitted by the Signal Fish client.
25///
26/// Each variant corresponds to either a [`ServerMessage`] received from the
27/// signaling server or a synthetic event generated by the transport layer.
28///
29/// # Synthetic events
30///
31/// | Variant | Origin |
32/// |---|---|
33/// | [`Connected`](Self::Connected) | Transport layer opened successfully |
34/// | [`Disconnected`](Self::Disconnected) | Transport layer closed or errored |
35/// | [`DecodeFailed`](Self::DecodeFailed) | An inbound frame could not be decoded |
36///
37/// # Example
38///
39/// ```text
40/// // Assuming `events` is an async receiver of SignalFishEvent:
41/// match event {
42///     SignalFishEvent::RoomJoined { room_code, current_players, .. } => { /* … */ }
43///     SignalFishEvent::PlayerJoined { player } => { /* … */ }
44///     SignalFishEvent::Disconnected { reason, .. } => { /* … */ }
45///     _ => {}
46/// }
47/// ```
48#[derive(Clone)]
49pub enum SignalFishEvent {
50    // ── Synthetic events ────────────────────────────────────────────
51    /// The client has started and will begin communicating with the server.
52    ///
53    /// This is a **synthetic event** — it is not triggered by a server message.
54    ///
55    /// - **`SignalFishClient`** (async): emitted at the start of the transport
56    ///   loop, after the transport has already been connected via
57    ///   `.connect().await`.
58    /// - **`SignalFishPollingClient`**: emitted once
59    ///   [`Transport::is_ready()`](crate::Transport::is_ready) returns `true`
60    ///   during a [`poll()`](crate::SignalFishPollingClient::poll) cycle. For
61    ///   transports that are already connected at construction time, this is
62    ///   the first `poll()` call. For transports with asynchronous handshakes
63    ///   (e.g., `EmscriptenWebSocketTransport`), `Connected` is deferred
64    ///   until the handshake completes.
65    Connected,
66
67    /// The transport connection was closed.
68    Disconnected {
69        /// Human-readable reason for the disconnection, if available.
70        ///
71        /// When the transport captured a WebSocket Close frame with a reason
72        /// (see [`Transport::close_info`](crate::Transport::close_info)),
73        /// it is included here as `"closed by server: …"`.
74        reason: Option<String>,
75        /// The most recent `Error`/`AuthenticationError` received on this
76        /// connection, if any.
77        ///
78        /// This is a correlation aid, not a server-attributed close reason:
79        /// a server that evicts a slow consumer writes a best-effort
80        /// `Error { error_code: SlowConsumer }` farewell before closing, and
81        /// when that frame arrives it is surfaced here so the disconnect can
82        /// be attributed. The farewell may never arrive (the socket is
83        /// congested by definition), in which case this is `None`.
84        last_server_error: Option<ServerErrorInfo>,
85    },
86
87    /// An inbound server frame could not be decoded into a
88    /// [`ServerMessage`].
89    ///
90    /// This is a **synthetic event**. The connection stays open and later
91    /// frames are unaffected. Typical causes: a server newer than this SDK
92    /// (an unknown message `type`, or an unknown `error_code` string inside
93    /// an otherwise-known message), a proxy injecting non-protocol frames,
94    /// or payload corruption.
95    ///
96    /// Every undecodable frame processed during normal operation produces
97    /// exactly one `DecodeFailed` event (no coalescing) and increments the
98    /// `messages_undecodable` counter in
99    /// [`ClientStats`](crate::ClientStats). Steady growth of that counter
100    /// means protocol drift (upgrade this SDK) or a corrupting middlebox. The
101    /// async driver delivers the event with channel backpressure; the polling
102    /// driver returns it from the current bounded polling cycle. Explicit
103    /// shutdown/close boundaries retain their documented delivery caveats.
104    DecodeFailed {
105        /// The wire `type` tag, when the frame was valid JSON with one.
106        ///
107        /// `Some("Error")` plus a decode failure strongly implies an
108        /// `error_code` string this SDK does not know; any other
109        /// `Some(...)` implies an unknown or malformed message type;
110        /// `None` implies the frame was not valid JSON at all.
111        message_type: Option<String>,
112        /// The deserialization error text.
113        error: String,
114        /// The raw frame text, truncated to at most
115        /// [`DECODE_FAILED_RAW_PREFIX_MAX`] bytes on a UTF-8 boundary.
116        raw_prefix: String,
117    },
118
119    /// The server violated protocol-v3 delivery-accountability invariants.
120    ProtocolViolation {
121        /// Stable category suitable for metrics and policy handling.
122        kind: ProtocolViolationKind,
123        /// Detailed diagnostic retaining sender/epoch/sequence context.
124        diagnostic: String,
125    },
126
127    // ── Authentication ──────────────────────────────────────────────
128    /// Authentication succeeded.
129    Authenticated {
130        /// Application name confirmed by the server.
131        app_name: String,
132        /// Organization the app belongs to, if any.
133        organization: Option<String>,
134        /// Rate limits enforced for this application.
135        rate_limits: RateLimitInfo,
136    },
137
138    /// SDK/protocol compatibility details advertised after authentication.
139    ///
140    /// Wrapped as a single payload rather than flattened because most fields
141    /// are optional configuration details that callers typically access as a group.
142    ProtocolInfo(ProtocolInfoPayload),
143
144    /// Authentication failed.
145    AuthenticationError {
146        /// Human-readable error description.
147        error: String,
148        /// Structured error code for programmatic handling.
149        error_code: ErrorCode,
150    },
151
152    // ── Room lifecycle ──────────────────────────────────────────────
153    /// Successfully joined a room. Fields are flattened from [`RoomJoinedPayload`].
154    ///
155    /// [`RoomJoinedPayload`]: crate::protocol::RoomJoinedPayload
156    RoomJoined {
157        /// Unique room identifier.
158        room_id: RoomId,
159        /// Human-readable room code.
160        room_code: String,
161        /// The local player's identifier.
162        player_id: PlayerId,
163        /// Name of the game this room is for.
164        game_name: String,
165        /// Maximum number of players allowed.
166        max_players: u8,
167        /// Whether the room supports authority delegation.
168        supports_authority: bool,
169        /// Players already present in the room.
170        current_players: Vec<PlayerInfo>,
171        /// Whether the local player is the authority.
172        is_authority: bool,
173        /// Current lobby readiness state.
174        lobby_state: LobbyState,
175        /// Players that have signaled readiness.
176        ready_players: Vec<PlayerId>,
177        /// Relay transport type label (e.g. `"auto"`, `"tcp"`).
178        relay_type: String,
179        /// Spectators currently watching.
180        current_spectators: Vec<SpectatorInfo>,
181        /// ICE (STUN/TURN) servers for early WebRTC candidate gathering
182        /// (protocol v3 "pre-gather"). Empty unless the server opted this
183        /// connection into ICE pre-gather.
184        ice_servers: Vec<IceServer>,
185        /// Server-issued token to retain for an unexpected disconnect.
186        reconnection_token: Option<String>,
187    },
188
189    /// Failed to join a room.
190    RoomJoinFailed {
191        /// Human-readable failure reason.
192        reason: String,
193        /// Structured error code, if provided.
194        error_code: Option<ErrorCode>,
195    },
196
197    /// Successfully left the current room.
198    RoomLeft,
199
200    // ── Player presence ─────────────────────────────────────────────
201    /// Another player joined the room.
202    PlayerJoined {
203        /// Information about the new player.
204        player: PlayerInfo,
205    },
206
207    /// Another player left the room.
208    PlayerLeft {
209        /// Identifier of the player who left.
210        player_id: PlayerId,
211        /// Departed incarnation epoch (protocol v3 only).
212        epoch: Option<u32>,
213        /// Terminal relay watermark (protocol v3 only).
214        final_seq: Option<u64>,
215    },
216
217    // ── Game data ───────────────────────────────────────────────────
218    /// JSON game data received from another player.
219    GameData {
220        /// Identifier of the sending player.
221        from_player: PlayerId,
222        /// Arbitrary JSON payload.
223        data: serde_json::Value,
224        /// Server-stamped sequence number (protocol v3 only).
225        seq: Option<u64>,
226        /// Server-tracked sender incarnation (protocol v3 only).
227        epoch: Option<u32>,
228        /// Echoed delivery class (protocol v3 only).
229        class: Option<DeliveryClass>,
230        /// Coalescing key for latest delivery (protocol v3 only).
231        key: Option<u32>,
232    },
233
234    /// Binary game data received from another player.
235    GameDataBinary {
236        /// Identifier of the sending player.
237        from_player: PlayerId,
238        /// Encoding format of the binary payload.
239        encoding: GameDataEncoding,
240        /// Raw binary payload.
241        payload: Vec<u8>,
242        /// Mandatory non-zero server sequence for a v3 binary envelope.
243        seq: Option<u64>,
244        /// Mandatory non-zero sender incarnation for a v3 binary envelope.
245        epoch: Option<u32>,
246    },
247
248    // ── Authority ───────────────────────────────────────────────────
249    /// The room's authority assignment changed.
250    AuthorityChanged {
251        /// The player who now holds authority, if any.
252        authority_player: Option<PlayerId>,
253        /// Whether the local player is now the authority.
254        you_are_authority: bool,
255    },
256
257    /// Response to an authority request.
258    AuthorityResponse {
259        /// Whether the request was granted.
260        granted: bool,
261        /// Human-readable reason if the request was denied.
262        reason: Option<String>,
263        /// Structured error code, if provided.
264        error_code: Option<ErrorCode>,
265    },
266
267    // ── Lobby ───────────────────────────────────────────────────────
268    /// The lobby readiness state changed.
269    LobbyStateChanged {
270        /// New lobby state.
271        lobby_state: LobbyState,
272        /// Players that have signaled readiness.
273        ready_players: Vec<PlayerId>,
274        /// Whether all players are ready.
275        all_ready: bool,
276    },
277
278    /// The game is starting with peer connection information.
279    GameStarting {
280        /// Connection details for every peer.
281        peer_connections: Vec<PeerConnectionInfo>,
282    },
283
284    // ── Mesh / WebRTC signaling (protocol v3) ───────────────────────
285    /// The server delivered this client's per-recipient session plan.
286    ///
287    /// **Protocol v3 only.** Arrives only on a v3-negotiated connection.
288    ///
289    /// May arrive multiple times (host re-election, late-join re-plan); each
290    /// one **fully replaces** the previous plan. Fields are flattened from
291    /// [`SessionPlanPayload`].
292    ///
293    /// [`SessionPlanPayload`]: crate::protocol::SessionPlanPayload
294    SessionPlan {
295        /// Chosen session topology.
296        topology: Topology,
297        /// Chosen data-path transport.
298        transport: TransportKind,
299        /// The elected host (present for `host` topology).
300        host: Option<PlayerId>,
301        /// Peers this client should connect to, each with its `initiate` flag.
302        peers: Vec<SessionPeer>,
303        /// ICE (STUN/TURN) servers for WebRTC.
304        ice_servers: Vec<IceServer>,
305        /// The universal fallback transport (always relay).
306        fallback: TransportKind,
307    },
308
309    /// A late-joining peer to connect to after the session was finalized.
310    ///
311    /// **Protocol v3 only.** Arrives only on a v3-negotiated connection.
312    NewPeer {
313        /// The new peer's identifier.
314        peer_id: PlayerId,
315        /// Whether this client sends the WebRTC offer (server-assigned; obey it).
316        you_initiate: bool,
317    },
318
319    /// An opaque WebRTC signal relayed from a peer.
320    ///
321    /// **Protocol v3 only.** Arrives only on a v3-negotiated connection.
322    ///
323    /// Convert with [`PeerSignal::try_from(&signal)`](crate::PeerSignal) for the
324    /// common offer/answer/ICE-candidate shapes; the raw `Value` is preserved
325    /// for any other shape.
326    SignalReceived {
327        /// The peer the signal came from.
328        from: PlayerId,
329        /// The opaque signal payload.
330        signal: serde_json::Value,
331    },
332
333    /// A peer's data-path transport state changed (informational).
334    ///
335    /// **Protocol v3 only.** Arrives only on a v3-negotiated connection.
336    PeerTransportStatus {
337        /// The peer whose transport state changed.
338        peer_id: PlayerId,
339        /// The transport being reported on.
340        transport: TransportKind,
341        /// Whether that transport is connected for the peer.
342        connected: bool,
343    },
344
345    /// Cumulative relay-delivery diagnostics (protocol v3 only).
346    RelayStats {
347        interval_ms: u64,
348        sent_to_you: u64,
349        dropped_for_you: u64,
350        backpressure_events: u64,
351    },
352
353    /// Graceful server-shutdown advisory (protocol v3 only).
354    GoingAway {
355        deadline_ms: u64,
356        retry_after_secs: Option<u64>,
357    },
358
359    /// Exact delivery-accountability report (protocol v3 only).
360    DeliveryReport(DeliveryReportPayload),
361
362    // ── Heartbeat ───────────────────────────────────────────────────
363    /// Pong response to a ping.
364    Pong,
365
366    // ── Reconnection ────────────────────────────────────────────────
367    /// Reconnection succeeded. Fields are flattened from [`ReconnectedPayload`].
368    ///
369    /// [`ReconnectedPayload`]: crate::protocol::ReconnectedPayload
370    Reconnected {
371        /// Unique room identifier.
372        room_id: RoomId,
373        /// Human-readable room code.
374        room_code: String,
375        /// The local player's identifier.
376        player_id: PlayerId,
377        /// Name of the game this room is for.
378        game_name: String,
379        /// Maximum number of players allowed.
380        max_players: u8,
381        /// Whether the room supports authority delegation.
382        supports_authority: bool,
383        /// Players currently in the room.
384        current_players: Vec<PlayerInfo>,
385        /// Whether the local player is the authority.
386        is_authority: bool,
387        /// Current lobby readiness state.
388        lobby_state: LobbyState,
389        /// Players that have signaled readiness.
390        ready_players: Vec<PlayerId>,
391        /// Relay transport type label.
392        relay_type: String,
393        /// Spectators currently watching.
394        current_spectators: Vec<SpectatorInfo>,
395        /// ICE (STUN/TURN) servers for early WebRTC candidate gathering
396        /// (protocol v3 "pre-gather"). Empty unless the server opted this
397        /// connection into ICE pre-gather.
398        ice_servers: Vec<IceServer>,
399        /// Events that occurred while the client was disconnected.
400        missed_events: Vec<SignalFishEvent>,
401        /// Completeness of the replayed control-event suffix.
402        replay: Option<ReplayStatus>,
403        /// Authoritative relay baselines for current senders.
404        sender_watermarks: Vec<SenderWatermark>,
405        /// Fresh token replacing the consumed reconnect token.
406        reconnection_token: Option<String>,
407    },
408
409    /// Reconnection failed.
410    ReconnectionFailed {
411        /// Human-readable failure reason.
412        reason: String,
413        /// Structured error code.
414        error_code: ErrorCode,
415    },
416
417    /// Another player reconnected to the room.
418    PlayerReconnected {
419        /// Identifier of the player who reconnected.
420        player_id: PlayerId,
421        /// New incarnation epoch (protocol v3 only).
422        epoch: Option<u32>,
423    },
424
425    // ── Spectator ───────────────────────────────────────────────────
426    /// Successfully joined a room as a spectator.
427    /// Fields are flattened from [`SpectatorJoinedPayload`].
428    ///
429    /// [`SpectatorJoinedPayload`]: crate::protocol::SpectatorJoinedPayload
430    SpectatorJoined {
431        /// Unique room identifier.
432        room_id: RoomId,
433        /// Human-readable room code.
434        room_code: String,
435        /// The local spectator's identifier.
436        spectator_id: PlayerId,
437        /// Name of the game this room is for.
438        game_name: String,
439        /// Players currently in the room.
440        current_players: Vec<PlayerInfo>,
441        /// Spectators currently watching.
442        current_spectators: Vec<SpectatorInfo>,
443        /// Current lobby readiness state.
444        lobby_state: LobbyState,
445        /// Reason the spectator state changed, if applicable.
446        reason: Option<SpectatorStateChangeReason>,
447    },
448
449    /// Failed to join as a spectator.
450    SpectatorJoinFailed {
451        /// Human-readable failure reason.
452        reason: String,
453        /// Structured error code, if provided.
454        error_code: Option<ErrorCode>,
455    },
456
457    /// Successfully left spectator mode.
458    SpectatorLeft {
459        /// Room identifier, if available.
460        room_id: Option<RoomId>,
461        /// Room code, if available.
462        room_code: Option<String>,
463        /// Reason for leaving, if available.
464        reason: Option<SpectatorStateChangeReason>,
465        /// Remaining spectators in the room.
466        current_spectators: Vec<SpectatorInfo>,
467    },
468
469    /// Another spectator joined the room.
470    NewSpectatorJoined {
471        /// Information about the new spectator.
472        spectator: SpectatorInfo,
473        /// All spectators currently watching.
474        current_spectators: Vec<SpectatorInfo>,
475        /// Reason for the state change, if available.
476        reason: Option<SpectatorStateChangeReason>,
477    },
478
479    /// Another spectator disconnected from the room.
480    SpectatorDisconnected {
481        /// Identifier of the spectator who disconnected.
482        spectator_id: PlayerId,
483        /// Reason for disconnection, if available.
484        reason: Option<SpectatorStateChangeReason>,
485        /// Remaining spectators in the room.
486        current_spectators: Vec<SpectatorInfo>,
487    },
488
489    // ── Errors ──────────────────────────────────────────────────────
490    /// A generic server error.
491    Error {
492        /// Human-readable error message.
493        message: String,
494        /// Structured error code, if provided.
495        error_code: Option<ErrorCode>,
496    },
497}
498
499impl std::fmt::Debug for SignalFishEvent {
500    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501        // Events can carry reconnect credentials and arbitrary application
502        // payloads. Keep the ubiquitous `Debug` path safe for production logs;
503        // callers can explicitly inspect fields after pattern matching.
504        f.write_str(match self {
505            Self::Connected => "Connected",
506            Self::Disconnected { .. } => "Disconnected",
507            Self::DecodeFailed { .. } => "DecodeFailed",
508            Self::ProtocolViolation { .. } => "ProtocolViolation",
509            Self::Authenticated { .. } => "Authenticated",
510            Self::ProtocolInfo(_) => "ProtocolInfo",
511            Self::AuthenticationError { .. } => "AuthenticationError",
512            Self::RoomJoined { .. } => "RoomJoined",
513            Self::RoomJoinFailed { .. } => "RoomJoinFailed",
514            Self::RoomLeft => "RoomLeft",
515            Self::PlayerJoined { .. } => "PlayerJoined",
516            Self::PlayerLeft { .. } => "PlayerLeft",
517            Self::GameData { .. } => "GameData",
518            Self::GameDataBinary { .. } => "GameDataBinary",
519            Self::AuthorityChanged { .. } => "AuthorityChanged",
520            Self::AuthorityResponse { .. } => "AuthorityResponse",
521            Self::LobbyStateChanged { .. } => "LobbyStateChanged",
522            Self::GameStarting { .. } => "GameStarting",
523            Self::SessionPlan { .. } => "SessionPlan",
524            Self::NewPeer { .. } => "NewPeer",
525            Self::SignalReceived { .. } => "SignalReceived",
526            Self::PeerTransportStatus { .. } => "PeerTransportStatus",
527            Self::RelayStats { .. } => "RelayStats",
528            Self::GoingAway { .. } => "GoingAway",
529            Self::DeliveryReport(_) => "DeliveryReport",
530            Self::Pong => "Pong",
531            Self::Reconnected { .. } => "Reconnected",
532            Self::ReconnectionFailed { .. } => "ReconnectionFailed",
533            Self::PlayerReconnected { .. } => "PlayerReconnected",
534            Self::SpectatorJoined { .. } => "SpectatorJoined",
535            Self::SpectatorJoinFailed { .. } => "SpectatorJoinFailed",
536            Self::SpectatorLeft { .. } => "SpectatorLeft",
537            Self::NewSpectatorJoined { .. } => "NewSpectatorJoined",
538            Self::SpectatorDisconnected { .. } => "SpectatorDisconnected",
539            Self::Error { .. } => "Error",
540        })
541    }
542}
543
544/// Category of a delivery-accountability protocol violation.
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546pub enum ProtocolViolationKind {
547    Snapshot,
548    Lifecycle,
549    DeliveryGap,
550    Counters,
551    Causality,
552    Stamp,
553    UnexpectedMetadata,
554}
555
556impl ProtocolViolationKind {
557    #[cfg(any(test, feature = "tokio-runtime", feature = "polling-client"))]
558    pub(crate) fn from_diagnostic(diagnostic: &str) -> Self {
559        if diagnostic.contains("immediately followed") || diagnostic.contains("preceding causal") {
560            Self::Causality
561        } else if diagnostic.contains("PlayerLeft")
562            || diagnostic.contains("PlayerReconnected")
563            || diagnostic.contains("announced epoch")
564        {
565            Self::Lifecycle
566        } else if diagnostic.contains("snapshot") || diagnostic.contains("watermark") {
567            Self::Snapshot
568        } else if diagnostic.contains("counter") || diagnostic.contains("RelayStats") {
569            Self::Counters
570        } else if diagnostic.contains("gap") || diagnostic.contains("DeliveryReport") {
571            Self::DeliveryGap
572        } else if diagnostic.contains("v2")
573            || diagnostic.contains("class/key")
574            || diagnostic.contains("frame representation")
575        {
576            Self::UnexpectedMetadata
577        } else {
578            Self::Stamp
579        }
580    }
581}
582
583/// Maximum number of bytes of raw frame text preserved in
584/// [`SignalFishEvent::DecodeFailed::raw_prefix`].
585///
586/// Truncation always lands on a UTF-8 character boundary, so the prefix may
587/// be a few bytes shorter than this cap.
588pub const DECODE_FAILED_RAW_PREFIX_MAX: usize = 512;
589
590/// A server-sent error remembered for disconnect attribution.
591///
592/// Carried by [`SignalFishEvent::Disconnected::last_server_error`]: the most
593/// recent `Error` or `AuthenticationError` frame received on the connection.
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct ServerErrorInfo {
596    /// Human-readable error message from the server.
597    pub message: String,
598    /// Structured error code, if the server provided one.
599    pub error_code: Option<ErrorCode>,
600}
601
602impl SignalFishEvent {
603    /// Builds the [`DecodeFailed`](Self::DecodeFailed) event for a frame that
604    /// failed to deserialize.
605    ///
606    /// Shared by the async and polling clients so both surface identical
607    /// diagnostics: the wire `type` tag when the frame was valid JSON, the
608    /// serde error text, and a bounded raw prefix.
609    #[cfg(any(feature = "tokio-runtime", feature = "polling-client"))]
610    pub(crate) fn decode_failed(raw: &str, error: &serde_json::Error) -> Self {
611        // Compute the bounded prefix once and reuse it for both the type-tag
612        // recovery and the stored `raw_prefix`, so work stays bounded on large
613        // or hostile input.
614        let prefix = truncate_on_char_boundary(raw, DECODE_FAILED_RAW_PREFIX_MAX);
615        // Only recover the wire `type` tag when the frame was well-formed JSON
616        // that simply didn't match our types (a `Data` error — e.g. an unknown
617        // message type or error-code token). Malformed JSON (`Syntax`/`Eof`)
618        // has no recoverable tag, so skip the re-parse entirely rather than
619        // re-scanning untrusted garbage. Parsing only the bounded prefix caps
620        // the secondary parse; a frame larger than the cap yields `None`
621        // (its `type` is still visible in `raw_prefix`).
622        let message_type = if error.classify() == serde_json::error::Category::Data {
623            serde_json::from_str::<serde_json::Value>(prefix)
624                .ok()
625                .and_then(|v| v.get("type")?.as_str().map(str::to_string))
626        } else {
627            None
628        };
629        Self::DecodeFailed {
630            message_type,
631            error: error.to_string(),
632            raw_prefix: prefix.to_string(),
633        }
634    }
635}
636
637/// Returns the longest prefix of `s` that is at most `max_bytes` long and
638/// ends on a UTF-8 character boundary.
639#[cfg(any(feature = "tokio-runtime", feature = "polling-client"))]
640fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> &str {
641    if s.len() <= max_bytes {
642        return s;
643    }
644    let mut end = max_bytes;
645    while end > 0 && !s.is_char_boundary(end) {
646        end -= 1;
647    }
648    s.get(..end).unwrap_or_default()
649}
650
651// ── Conversion ──────────────────────────────────────────────────────
652
653impl From<ServerMessage> for SignalFishEvent {
654    fn from(msg: ServerMessage) -> Self {
655        match msg {
656            ServerMessage::Authenticated {
657                app_name,
658                organization,
659                rate_limits,
660            } => Self::Authenticated {
661                app_name,
662                organization,
663                rate_limits,
664            },
665            ServerMessage::ProtocolInfo(payload) => Self::ProtocolInfo(payload),
666            ServerMessage::AuthenticationError { error, error_code } => {
667                Self::AuthenticationError { error, error_code }
668            }
669            ServerMessage::RoomJoined(payload) => {
670                let p = *payload;
671                Self::RoomJoined {
672                    room_id: p.room_id,
673                    room_code: p.room_code,
674                    player_id: p.player_id,
675                    game_name: p.game_name,
676                    max_players: p.max_players,
677                    supports_authority: p.supports_authority,
678                    current_players: p.current_players,
679                    is_authority: p.is_authority,
680                    lobby_state: p.lobby_state,
681                    ready_players: p.ready_players,
682                    relay_type: p.relay_type,
683                    current_spectators: p.current_spectators,
684                    ice_servers: p.ice_servers,
685                    reconnection_token: p.reconnection_token,
686                }
687            }
688            ServerMessage::RoomJoinFailed { reason, error_code } => {
689                Self::RoomJoinFailed { reason, error_code }
690            }
691            ServerMessage::RoomLeft => Self::RoomLeft,
692            ServerMessage::PlayerJoined { player } => Self::PlayerJoined { player },
693            ServerMessage::PlayerLeft {
694                player_id,
695                epoch,
696                final_seq,
697            } => Self::PlayerLeft {
698                player_id,
699                epoch,
700                final_seq,
701            },
702            ServerMessage::GameData {
703                from_player,
704                data,
705                seq,
706                epoch,
707                class,
708                key,
709            } => Self::GameData {
710                from_player,
711                data,
712                seq,
713                epoch,
714                class,
715                key,
716            },
717            ServerMessage::GameDataBinary {
718                from_player,
719                encoding,
720                payload,
721                seq,
722                epoch,
723            } => Self::GameDataBinary {
724                from_player,
725                encoding,
726                payload,
727                seq,
728                epoch,
729            },
730            ServerMessage::AuthorityChanged {
731                authority_player,
732                you_are_authority,
733            } => Self::AuthorityChanged {
734                authority_player,
735                you_are_authority,
736            },
737            ServerMessage::AuthorityResponse {
738                granted,
739                reason,
740                error_code,
741            } => Self::AuthorityResponse {
742                granted,
743                reason,
744                error_code,
745            },
746            ServerMessage::LobbyStateChanged {
747                lobby_state,
748                ready_players,
749                all_ready,
750            } => Self::LobbyStateChanged {
751                lobby_state,
752                ready_players,
753                all_ready,
754            },
755            ServerMessage::GameStarting { peer_connections } => {
756                Self::GameStarting { peer_connections }
757            }
758            ServerMessage::Pong => Self::Pong,
759            ServerMessage::Reconnected(payload) => {
760                let p = *payload;
761                Self::Reconnected {
762                    room_id: p.room_id,
763                    room_code: p.room_code,
764                    player_id: p.player_id,
765                    game_name: p.game_name,
766                    max_players: p.max_players,
767                    supports_authority: p.supports_authority,
768                    current_players: p.current_players,
769                    is_authority: p.is_authority,
770                    lobby_state: p.lobby_state,
771                    ready_players: p.ready_players,
772                    relay_type: p.relay_type,
773                    current_spectators: p.current_spectators,
774                    ice_servers: p.ice_servers,
775                    missed_events: p
776                        .missed_events
777                        .into_iter()
778                        .map(SignalFishEvent::from)
779                        .collect(),
780                    replay: p.replay,
781                    sender_watermarks: p.sender_watermarks,
782                    reconnection_token: p.reconnection_token,
783                }
784            }
785            ServerMessage::ReconnectionFailed { reason, error_code } => {
786                Self::ReconnectionFailed { reason, error_code }
787            }
788            ServerMessage::PlayerReconnected { player_id, epoch } => {
789                Self::PlayerReconnected { player_id, epoch }
790            }
791            ServerMessage::SpectatorJoined(payload) => {
792                let p = *payload;
793                Self::SpectatorJoined {
794                    room_id: p.room_id,
795                    room_code: p.room_code,
796                    spectator_id: p.spectator_id,
797                    game_name: p.game_name,
798                    current_players: p.current_players,
799                    current_spectators: p.current_spectators,
800                    lobby_state: p.lobby_state,
801                    reason: p.reason,
802                }
803            }
804            ServerMessage::SpectatorJoinFailed { reason, error_code } => {
805                Self::SpectatorJoinFailed { reason, error_code }
806            }
807            ServerMessage::SpectatorLeft {
808                room_id,
809                room_code,
810                reason,
811                current_spectators,
812            } => Self::SpectatorLeft {
813                room_id,
814                room_code,
815                reason,
816                current_spectators,
817            },
818            ServerMessage::NewSpectatorJoined {
819                spectator,
820                current_spectators,
821                reason,
822            } => Self::NewSpectatorJoined {
823                spectator,
824                current_spectators,
825                reason,
826            },
827            ServerMessage::SpectatorDisconnected {
828                spectator_id,
829                reason,
830                current_spectators,
831            } => Self::SpectatorDisconnected {
832                spectator_id,
833                reason,
834                current_spectators,
835            },
836            ServerMessage::Error {
837                message,
838                error_code,
839            } => Self::Error {
840                message,
841                error_code,
842            },
843            ServerMessage::Signal { from, signal } => Self::SignalReceived { from, signal },
844            ServerMessage::NewPeer {
845                peer_id,
846                you_initiate,
847            } => Self::NewPeer {
848                peer_id,
849                you_initiate,
850            },
851            ServerMessage::SessionPlan(payload) => {
852                let p = *payload;
853                Self::SessionPlan {
854                    topology: p.topology,
855                    transport: p.transport,
856                    host: p.host,
857                    peers: p.peers,
858                    ice_servers: p.ice_servers,
859                    fallback: p.fallback,
860                }
861            }
862            ServerMessage::PeerTransportStatus {
863                peer_id,
864                transport,
865                connected,
866            } => Self::PeerTransportStatus {
867                peer_id,
868                transport,
869                connected,
870            },
871            ServerMessage::RelayStats {
872                interval_ms,
873                sent_to_you,
874                dropped_for_you,
875                backpressure_events,
876            } => Self::RelayStats {
877                interval_ms,
878                sent_to_you,
879                dropped_for_you,
880                backpressure_events,
881            },
882            ServerMessage::GoingAway {
883                deadline_ms,
884                retry_after_secs,
885            } => Self::GoingAway {
886                deadline_ms,
887                retry_after_secs,
888            },
889            ServerMessage::DeliveryReport(payload) => Self::DeliveryReport(*payload),
890        }
891    }
892}
893
894#[cfg(test)]
895#[allow(
896    clippy::unwrap_used,
897    clippy::expect_used,
898    clippy::panic,
899    clippy::todo,
900    clippy::unimplemented,
901    clippy::indexing_slicing
902)]
903mod tests {
904    use super::*;
905    use crate::protocol::{
906        LobbyState, ReconnectedPayload, RoomJoinedPayload, SpectatorJoinedPayload,
907    };
908
909    #[test]
910    fn connected_event_is_constructible() {
911        let event = SignalFishEvent::Connected;
912        let debug = format!("{event:?}");
913        assert!(debug.contains("Connected"));
914    }
915
916    #[test]
917    fn debug_never_exposes_reconnection_tokens_or_application_payloads() {
918        let event = SignalFishEvent::RoomJoined {
919            room_id: uuid::Uuid::nil(),
920            room_code: "ROOM".into(),
921            player_id: uuid::Uuid::nil(),
922            game_name: "game".into(),
923            max_players: 2,
924            supports_authority: false,
925            current_players: vec![],
926            is_authority: false,
927            lobby_state: LobbyState::Waiting,
928            ready_players: vec![],
929            relay_type: "websocket".into(),
930            current_spectators: vec![],
931            ice_servers: vec![],
932            reconnection_token: Some("top-secret-token".into()),
933        };
934        let debug = format!("{event:?}");
935        assert_eq!(debug, "RoomJoined");
936        assert!(!debug.contains("top-secret-token"));
937    }
938
939    #[test]
940    fn violation_diagnostics_are_classified_by_semantic_precedence() {
941        let cases = [
942            (
943                "PlayerLeft final_seq disagreed with terminal watermark",
944                ProtocolViolationKind::Lifecycle,
945            ),
946            (
947                "DeliveryReport was not immediately followed by Error",
948                ProtocolViolationKind::Causality,
949            ),
950            (
951                "reconnect snapshot watermark mismatch",
952                ProtocolViolationKind::Snapshot,
953            ),
954            (
955                "cumulative counter moved backward",
956                ProtocolViolationKind::Counters,
957            ),
958            ("uncovered delivery gap", ProtocolViolationKind::DeliveryGap),
959            (
960                "v2 exposed class/key",
961                ProtocolViolationKind::UnexpectedMetadata,
962            ),
963            ("sequence stamp was zero", ProtocolViolationKind::Stamp),
964        ];
965        for (diagnostic, expected) in cases {
966            assert_eq!(ProtocolViolationKind::from_diagnostic(diagnostic), expected);
967        }
968    }
969
970    #[test]
971    fn disconnected_event_contains_reason() {
972        let event = SignalFishEvent::Disconnected {
973            reason: Some("server shutdown".into()),
974            last_server_error: None,
975        };
976        if let SignalFishEvent::Disconnected { reason, .. } = event {
977            assert_eq!(reason.as_deref(), Some("server shutdown"));
978        } else {
979            panic!("expected Disconnected variant");
980        }
981    }
982
983    #[test]
984    fn from_server_message_pong() {
985        let event = SignalFishEvent::from(ServerMessage::Pong);
986        assert!(matches!(event, SignalFishEvent::Pong));
987    }
988
989    #[test]
990    fn from_server_message_room_left() {
991        let event = SignalFishEvent::from(ServerMessage::RoomLeft);
992        assert!(matches!(event, SignalFishEvent::RoomLeft));
993    }
994
995    #[test]
996    fn from_server_message_room_joined_flattens_payload() {
997        let payload = RoomJoinedPayload {
998            room_id: uuid::Uuid::nil(),
999            room_code: "ABC123".into(),
1000            player_id: uuid::Uuid::nil(),
1001            game_name: "test-game".into(),
1002            max_players: 4,
1003            supports_authority: true,
1004            current_players: vec![],
1005            is_authority: false,
1006            lobby_state: LobbyState::Waiting,
1007            ready_players: vec![],
1008            relay_type: "auto".into(),
1009            current_spectators: vec![],
1010            ice_servers: vec![],
1011            reconnection_token: None,
1012        };
1013        let msg = ServerMessage::RoomJoined(Box::new(payload));
1014        let event = SignalFishEvent::from(msg);
1015        if let SignalFishEvent::RoomJoined {
1016            room_code,
1017            max_players,
1018            game_name,
1019            ..
1020        } = event
1021        {
1022            assert_eq!(room_code, "ABC123");
1023            assert_eq!(max_players, 4);
1024            assert_eq!(game_name, "test-game");
1025        } else {
1026            panic!("expected RoomJoined variant");
1027        }
1028    }
1029
1030    #[test]
1031    fn from_server_message_error() {
1032        let msg = ServerMessage::Error {
1033            message: "oops".into(),
1034            error_code: Some(ErrorCode::InternalError),
1035        };
1036        let event = SignalFishEvent::from(msg);
1037        if let SignalFishEvent::Error {
1038            message,
1039            error_code,
1040        } = event
1041        {
1042            assert_eq!(message, "oops");
1043            assert_eq!(error_code, Some(ErrorCode::InternalError));
1044        } else {
1045            panic!("expected Error variant");
1046        }
1047    }
1048
1049    #[test]
1050    fn event_is_clone() {
1051        let event = SignalFishEvent::Pong;
1052        let cloned = event.clone();
1053        assert!(matches!(cloned, SignalFishEvent::Pong));
1054    }
1055
1056    #[test]
1057    fn from_server_message_reconnected_flattens_payload() {
1058        let payload = ReconnectedPayload {
1059            room_id: uuid::Uuid::nil(),
1060            room_code: "RECON1".into(),
1061            player_id: uuid::Uuid::nil(),
1062            game_name: "recon-game".into(),
1063            max_players: 6,
1064            supports_authority: false,
1065            current_players: vec![],
1066            is_authority: true,
1067            lobby_state: LobbyState::Waiting,
1068            ready_players: vec![],
1069            relay_type: "tcp".into(),
1070            current_spectators: vec![],
1071            ice_servers: vec![],
1072            missed_events: vec![ServerMessage::Pong],
1073            replay: None,
1074            sender_watermarks: vec![],
1075            reconnection_token: None,
1076        };
1077        let msg = ServerMessage::Reconnected(Box::new(payload));
1078        let event = SignalFishEvent::from(msg);
1079        if let SignalFishEvent::Reconnected {
1080            room_code,
1081            max_players,
1082            is_authority,
1083            missed_events,
1084            ..
1085        } = event
1086        {
1087            assert_eq!(room_code, "RECON1");
1088            assert_eq!(max_players, 6);
1089            assert!(is_authority);
1090            assert_eq!(missed_events.len(), 1);
1091            assert!(matches!(missed_events[0], SignalFishEvent::Pong));
1092        } else {
1093            panic!("expected Reconnected variant");
1094        }
1095    }
1096
1097    #[test]
1098    fn from_server_message_spectator_joined_flattens_payload() {
1099        let payload = SpectatorJoinedPayload {
1100            room_id: uuid::Uuid::nil(),
1101            room_code: "SPEC1".into(),
1102            spectator_id: uuid::Uuid::nil(),
1103            game_name: "spec-game".into(),
1104            current_players: vec![],
1105            current_spectators: vec![],
1106            lobby_state: LobbyState::Waiting,
1107            reason: None,
1108        };
1109        let msg = ServerMessage::SpectatorJoined(Box::new(payload));
1110        let event = SignalFishEvent::from(msg);
1111        if let SignalFishEvent::SpectatorJoined {
1112            room_code,
1113            game_name,
1114            reason,
1115            ..
1116        } = event
1117        {
1118            assert_eq!(room_code, "SPEC1");
1119            assert_eq!(game_name, "spec-game");
1120            assert!(reason.is_none());
1121        } else {
1122            panic!("expected SpectatorJoined variant");
1123        }
1124    }
1125
1126    #[test]
1127    fn from_server_message_game_data_binary() {
1128        let msg = ServerMessage::GameDataBinary {
1129            from_player: uuid::Uuid::nil(),
1130            encoding: GameDataEncoding::MessagePack,
1131            payload: vec![0xDE, 0xAD],
1132            seq: None,
1133            epoch: None,
1134        };
1135        let event = SignalFishEvent::from(msg);
1136        if let SignalFishEvent::GameDataBinary {
1137            from_player,
1138            encoding,
1139            payload,
1140            seq,
1141            epoch,
1142        } = event
1143        {
1144            assert_eq!(from_player, uuid::Uuid::nil());
1145            assert!(matches!(encoding, GameDataEncoding::MessagePack));
1146            assert_eq!(payload, vec![0xDE, 0xAD]);
1147            assert!(seq.is_none());
1148            assert!(epoch.is_none());
1149        } else {
1150            panic!("expected GameDataBinary variant");
1151        }
1152    }
1153
1154    #[test]
1155    fn from_server_message_authentication_error() {
1156        let msg = ServerMessage::AuthenticationError {
1157            error: "bad token".into(),
1158            error_code: ErrorCode::InvalidAppId,
1159        };
1160        let event = SignalFishEvent::from(msg);
1161        if let SignalFishEvent::AuthenticationError { error, error_code } = event {
1162            assert_eq!(error, "bad token");
1163            assert_eq!(error_code, ErrorCode::InvalidAppId);
1164        } else {
1165            panic!("expected AuthenticationError variant");
1166        }
1167    }
1168
1169    /// `decode_failed` bounds its work: the wire `type` tag is recovered only
1170    /// from well-formed JSON within the prefix cap, never by re-parsing a full
1171    /// oversized or malformed frame.
1172    #[cfg(any(feature = "tokio-runtime", feature = "polling-client"))]
1173    #[test]
1174    fn decode_failed_recovers_type_only_for_bounded_well_formed_frames() {
1175        // 1. Small, well-formed frame, unknown type (a serde `Data` error) →
1176        //    type recovered; the whole frame fits in the prefix.
1177        let small = r#"{"type":"SomeFutureMessage","data":{}}"#;
1178        let err = serde_json::from_str::<ServerMessage>(small).unwrap_err();
1179        assert_eq!(err.classify(), serde_json::error::Category::Data);
1180        match SignalFishEvent::decode_failed(small, &err) {
1181            SignalFishEvent::DecodeFailed {
1182                message_type,
1183                raw_prefix,
1184                ..
1185            } => {
1186                assert_eq!(message_type.as_deref(), Some("SomeFutureMessage"));
1187                assert_eq!(raw_prefix, small);
1188            }
1189            other => panic!("expected DecodeFailed, got {other:?}"),
1190        }
1191
1192        // 2. Malformed JSON (a `Syntax` error) → the re-parse is skipped
1193        //    entirely, so no `type` is recovered.
1194        let garbage = "not valid json {{{";
1195        let err = serde_json::from_str::<ServerMessage>(garbage).unwrap_err();
1196        assert_ne!(err.classify(), serde_json::error::Category::Data);
1197        match SignalFishEvent::decode_failed(garbage, &err) {
1198            SignalFishEvent::DecodeFailed { message_type, .. } => {
1199                assert_eq!(message_type, None);
1200            }
1201            other => panic!("expected DecodeFailed, got {other:?}"),
1202        }
1203
1204        // 3. Well-formed unknown-type frame larger than the prefix cap → work
1205        //    stays bounded: `message_type` is None (the full frame is never
1206        //    re-parsed), while the type is still visible in the capped prefix.
1207        let big = format!(
1208            r#"{{"type":"SomeFutureMessage","data":{{"pad":"{}"}}}}"#,
1209            "x".repeat(DECODE_FAILED_RAW_PREFIX_MAX)
1210        );
1211        let err = serde_json::from_str::<ServerMessage>(&big).unwrap_err();
1212        assert_eq!(err.classify(), serde_json::error::Category::Data);
1213        match SignalFishEvent::decode_failed(&big, &err) {
1214            SignalFishEvent::DecodeFailed {
1215                message_type,
1216                raw_prefix,
1217                ..
1218            } => {
1219                assert_eq!(
1220                    message_type, None,
1221                    "an oversized frame must not be re-parsed to recover its type"
1222                );
1223                assert!(raw_prefix.len() <= DECODE_FAILED_RAW_PREFIX_MAX);
1224                assert!(
1225                    raw_prefix.contains("SomeFutureMessage"),
1226                    "the type remains visible in the bounded prefix"
1227                );
1228            }
1229            other => panic!("expected DecodeFailed, got {other:?}"),
1230        }
1231    }
1232}