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.
This commit is contained in:
+215
-26
@@ -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<Option<GameSocket>>,
|
||||
game: Signal<Option<GameView>>,
|
||||
over: Signal<Option<(Scores, Option<String>)>>,
|
||||
error: Signal<Option<String>>,
|
||||
closed: Signal<bool>,
|
||||
/// Reconnect attempts exhausted; only a manual retry resumes.
|
||||
gave_up: Signal<bool>,
|
||||
/// The server closed the connection deliberately (auth or game gone);
|
||||
/// retrying is pointless.
|
||||
fatal: Signal<bool>,
|
||||
attempts: Signal<u32>,
|
||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||
selected: Signal<Option<String>>,
|
||||
}
|
||||
|
||||
/// 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<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
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<u16>| {
|
||||
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<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
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<dyn Fn()>,
|
||||
) -> 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::<GameView>::None);
|
||||
@@ -78,32 +218,51 @@ pub fn GamePage(id: String) -> View {
|
||||
let selected = create_signal(Option::<String>::None);
|
||||
let over = create_signal(Option::<(Scores, Option<String>)>::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::<GameSocket>::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<dyn Fn()> = 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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user