From 57c6ffb0325c38af7bf14879df900c9d8bd3531a Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 18 Sep 2026 13:48:34 +0800 Subject: [PATCH] Reconnect the game websocket after connectivity loss The socket had no recovery path: a mid-game drop left the table showing stale state with no indication, and plays were silently swallowed by the dead channel. Surface the server close code from ws::connect and add a reconnect driver in the game page: transient losses retry with exponential backoff (capped, then a manual Retry), while deliberate closes (session expired, game gone) stop retrying. Reconnects are free resyncs because the server pushes a full state snapshot on connect. Also gate card clicks while disconnected, show a connection banner, and drop the ticking interval and pending retries on unmount. --- web/src/pages/game.rs | 241 +++++++++++++++++++++++++++++++++++++----- web/src/ws.rs | 15 ++- web/style.css | 21 ++++ 3 files changed, 248 insertions(+), 29 deletions(-) diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index 8aff79b..1c89c90 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -1,4 +1,7 @@ //! Live game page: table view over the websocket. +use std::cell::Cell; +use std::rc::Rc; + use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; @@ -70,6 +73,143 @@ fn move_banner(mv: MoveView) -> View { } } +/// Signals shared by the websocket connection and its reconnect attempts. +#[derive(Clone, Copy)] +struct ConnCtx { + socket: Signal>, + game: Signal>, + over: Signal)>>, + error: Signal>, + closed: Signal, + /// Reconnect attempts exhausted; only a manual retry resumes. + gave_up: Signal, + /// The server closed the connection deliberately (auth or game gone); + /// retrying is pointless. + fatal: Signal, + attempts: Signal, + capture_choice: Signal>)>>, + selected: Signal>, +} + +/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many. +const MAX_RECONNECT_ATTEMPTS: u32 = 10; + +fn backoff_ms(attempt: u32) -> u32 { + (1000u32 << attempt.min(5)).min(30_000) +} + +/// Connect the game websocket, wiring state updates and reconnects. +/// +/// The server pushes a full state snapshot on connect, so a reconnect is +/// also a resync: no client-side state merging is needed. +fn start_connect(id: Rc, ctx: ConnCtx, alive: Rc>) { + let on_message = { + let alive = alive.clone(); + move |msg: ServerMessage| { + if !alive.get() { + // The page is unmounted; its signals are disposed. + return; + } + match msg { + ServerMessage::State { game: g } => { + ctx.capture_choice.set(None); + ctx.selected.set(None); + // A received state proves the (re)connection works. + ctx.attempts.set(0); + ctx.gave_up.set(false); + ctx.closed.set(false); + ctx.game.set(Some(g)); + } + ServerMessage::GameOver { scores, winner } => { + ctx.over.set(Some((scores, winner))) + } + ServerMessage::Error { message, .. } => ctx.error.set(Some(message)), + } + } + }; + let on_close = { + let id = id.clone(); + let alive = alive.clone(); + move |code: Option| { + if !alive.get() { + return; + } + ctx.closed.set(true); + match code { + Some(4401) => { + ctx.fatal.set(true); + ctx.error + .set(Some("Session expired — please log in again.".to_string())); + } + Some(4403) | Some(4404) => { + ctx.fatal.set(true); + ctx.error + .set(Some("This game is no longer available.".to_string())); + } + _ => schedule_retry(id.clone(), ctx, alive.clone()), + } + } + }; + match ws::connect(&id, on_message, on_close) { + Some(s) => ctx.socket.set(Some(s)), + // WebSocket::open failed synchronously: treat as a transient loss. + None if alive.get() => { + ctx.closed.set(true); + schedule_retry(id, ctx, alive); + } + None => {} + } +} + +/// Retry `start_connect` with exponential backoff, unless we gave up. +fn schedule_retry(id: Rc, ctx: ConnCtx, alive: Rc>) { + let attempt = ctx.attempts.get(); + if attempt >= MAX_RECONNECT_ATTEMPTS { + ctx.gave_up.set(true); + return; + } + ctx.attempts.set(attempt + 1); + gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || { + if alive.get() { + start_connect(id, ctx, alive); + } + }) + .forget(); +} + +/// Slim banner shown over the table while the socket is down. +fn conn_banner( + closed: bool, + gave_up: bool, + fatal: bool, + has_game: bool, + reconnect: Rc, +) -> View { + if !closed || !has_game { + return view! {}; + } + if fatal { + view! { + div(class="conn-banner") { + "Connection closed by the server. " + a(href="/") { "Back to lobby" } + } + } + } else if gave_up { + view! { + div(class="conn-banner") { + "Connection lost." + button(class="button", on:click=move |_| reconnect()) { "Retry now" } + a(href="/") { "Back to lobby" } + } + } + } else { + view! { + div(class="conn-banner") { "Connection lost — reconnecting…" } + } + } +} + #[component(inline_props)] pub fn GamePage(id: String) -> View { let game = create_signal(Option::::None); @@ -78,32 +218,51 @@ pub fn GamePage(id: String) -> View { let selected = create_signal(Option::::None); let over = create_signal(Option::<(Scores, Option)>::None); let closed = create_signal(false); + let gave_up = create_signal(false); + let fatal = create_signal(false); + let attempts = create_signal(0u32); let socket = create_signal(Option::::None); // Ticking clock driving the hand-end countdown display. let now = create_signal(js_sys::Date::now()); - gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget(); + let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())); - { - let on_message = move |msg: ServerMessage| match msg { - ServerMessage::State { game: g } => { - capture_choice.set(None); - selected.set(None); - game.set(Some(g)); - } - ServerMessage::GameOver { scores, winner } => { - over.set(Some((scores, winner))); - } - ServerMessage::Error { message, .. } => error.set(Some(message)), - }; - let on_close = move || closed.set(true); - match ws::connect(&id, on_message, on_close) { - Some(s) => socket.set(Some(s)), - None => error.set(Some("Could not connect to the game".to_string())), + // Stops the ticker and any pending reconnect once the page unmounts. + let alive = Rc::new(Cell::new(true)); + on_cleanup({ + let alive = alive.clone(); + move || { + alive.set(false); + drop(ticker); } - } + }); + + let id = Rc::new(id); + let ctx = ConnCtx { + socket, + game, + over, + error, + closed, + gave_up, + fatal, + attempts, + capture_choice, + selected, + }; + start_connect(id.clone(), ctx, alive.clone()); + let reconnect: Rc = Rc::new(move || { + ctx.attempts.set(0); + ctx.gave_up.set(false); + ctx.closed.set(false); + start_connect(id.clone(), ctx, alive.clone()); + }); // Clicking a card in the player's own hand. let on_hand_card = move |code: String| { + if closed.get() { + // A dead socket would swallow the play silently. + return; + } let Some(g) = game.get_clone() else { return }; if g.your_turn != Some(true) { return; @@ -122,20 +281,50 @@ pub fn GamePage(id: String) -> View { } }; + let reconnect_banner = reconnect.clone(); view! { div(class="game-page") { (toast(error)) + (move || conn_banner( + closed.get(), + gave_up.get(), + fatal.get(), + game.get_clone().is_some(), + reconnect_banner.clone(), + )) (move || match game.get_clone() { None => { - let status = if closed.get() { - "Connection closed." + if fatal.get() { + view! { + div(class="panel status-panel") { + p { "Connection closed." } + p { a(href="/") { "Back to lobby" } } + } + } + } else if gave_up.get() { + let reconnect = reconnect.clone(); + view! { + div(class="panel status-panel") { + p { "Connection lost." } + p { + button(class="button primary", on:click=move |_| reconnect()) { + "Retry now" + } + } + p { a(href="/") { "Back to lobby" } } + } + } } else { - "Connecting to the game…" - }; - view! { - div(class="panel status-panel") { - p { (status) } - p { a(href="/") { "Back to lobby" } } + let status = if closed.get() { + "Connection lost — reconnecting…" + } else { + "Connecting to the game…" + }; + view! { + div(class="panel status-panel") { + p { (status) } + p { a(href="/") { "Back to lobby" } } + } } } } diff --git a/web/src/ws.rs b/web/src/ws.rs index 4cd259b..3e07eb9 100644 --- a/web/src/ws.rs +++ b/web/src/ws.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use futures::channel::mpsc; use futures::{SinkExt, StreamExt}; -use gloo_net::websocket::{futures::WebSocket, Message}; +use gloo_net::websocket::{futures::WebSocket, Message, WebSocketError}; use wasm_bindgen_futures::spawn_local; use crate::model::ServerMessage; @@ -53,10 +53,14 @@ impl GameSocket { /// Open the websocket for `game_id` and forward parsed server messages to /// `on_message`. Returns the socket handle, or `None` if the connection /// could not be created. +/// +/// `on_close` fires exactly once when the connection ends; it receives the +/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not +/// seated, 4404 unknown game) or `None` for an abnormal network loss. pub fn connect( game_id: &str, on_message: impl Fn(ServerMessage) + 'static, - on_close: impl Fn() + 'static, + on_close: impl Fn(Option) + 'static, ) -> Option { let ws = WebSocket::open(&ws_url(game_id)).ok()?; let (mut write, mut read) = ws.split(); @@ -72,6 +76,7 @@ pub fn connect( }); spawn_local(async move { + let mut close_code = None; while let Some(msg) = read.next().await { match msg { Ok(Message::Text(text)) => { @@ -80,10 +85,14 @@ pub fn connect( } } Ok(Message::Bytes(_)) => {} + Err(WebSocketError::ConnectionClose(e)) => { + close_code = Some(e.code); + break; + } Err(_) => break, } } - on_close(); + on_close(close_code); }); Some(GameSocket { diff --git a/web/style.css b/web/style.css index 9400934..2c7d151 100644 --- a/web/style.css +++ b/web/style.css @@ -418,6 +418,27 @@ table.matches td.lost { margin-left: 0.25rem; } +/* ---------- connection banner ---------- */ + +.conn-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + background: rgba(232, 197, 71, 0.15); + border: 1px solid var(--accent); + border-radius: 8px; + color: var(--accent); + padding: 0.4rem 1rem; + margin: 0.5rem auto 0; + width: fit-content; +} + +.conn-banner a { + color: var(--accent); + text-decoration: underline; +} + /* ---------- overlays ---------- */ .overlay {