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}