Reconnect the game websocket after connectivity loss
CI / Build and push docker image (push) Successful in 2m59s
CI / Build and push docker image (push) Successful in 2m59s
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:
+207
-18
@@ -1,4 +1,7 @@
|
|||||||
//! Live game page: table view over the websocket.
|
//! Live game page: table view over the websocket.
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
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)]
|
#[component(inline_props)]
|
||||||
pub fn GamePage(id: String) -> View {
|
pub fn GamePage(id: String) -> View {
|
||||||
let game = create_signal(Option::<GameView>::None);
|
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 selected = create_signal(Option::<String>::None);
|
||||||
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||||
let closed = create_signal(false);
|
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);
|
let socket = create_signal(Option::<GameSocket>::None);
|
||||||
// Ticking clock driving the hand-end countdown display.
|
// Ticking clock driving the hand-end countdown display.
|
||||||
let now = create_signal(js_sys::Date::now());
|
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()));
|
||||||
|
|
||||||
{
|
// Stops the ticker and any pending reconnect once the page unmounts.
|
||||||
let on_message = move |msg: ServerMessage| match msg {
|
let alive = Rc::new(Cell::new(true));
|
||||||
ServerMessage::State { game: g } => {
|
on_cleanup({
|
||||||
capture_choice.set(None);
|
let alive = alive.clone();
|
||||||
selected.set(None);
|
move || {
|
||||||
game.set(Some(g));
|
alive.set(false);
|
||||||
|
drop(ticker);
|
||||||
}
|
}
|
||||||
ServerMessage::GameOver { scores, winner } => {
|
});
|
||||||
over.set(Some((scores, winner)));
|
|
||||||
}
|
let id = Rc::new(id);
|
||||||
ServerMessage::Error { message, .. } => error.set(Some(message)),
|
let ctx = ConnCtx {
|
||||||
|
socket,
|
||||||
|
game,
|
||||||
|
over,
|
||||||
|
error,
|
||||||
|
closed,
|
||||||
|
gave_up,
|
||||||
|
fatal,
|
||||||
|
attempts,
|
||||||
|
capture_choice,
|
||||||
|
selected,
|
||||||
};
|
};
|
||||||
let on_close = move || closed.set(true);
|
start_connect(id.clone(), ctx, alive.clone());
|
||||||
match ws::connect(&id, on_message, on_close) {
|
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||||||
Some(s) => socket.set(Some(s)),
|
ctx.attempts.set(0);
|
||||||
None => error.set(Some("Could not connect to the game".to_string())),
|
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.
|
// Clicking a card in the player's own hand.
|
||||||
let on_hand_card = move |code: String| {
|
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 };
|
let Some(g) = game.get_clone() else { return };
|
||||||
if g.your_turn != Some(true) {
|
if g.your_turn != Some(true) {
|
||||||
return;
|
return;
|
||||||
@@ -122,13 +281,42 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let reconnect_banner = reconnect.clone();
|
||||||
view! {
|
view! {
|
||||||
div(class="game-page") {
|
div(class="game-page") {
|
||||||
(toast(error))
|
(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() {
|
(move || match game.get_clone() {
|
||||||
None => {
|
None => {
|
||||||
|
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 {
|
||||||
let status = if closed.get() {
|
let status = if closed.get() {
|
||||||
"Connection closed."
|
"Connection lost — reconnecting…"
|
||||||
} else {
|
} else {
|
||||||
"Connecting to the game…"
|
"Connecting to the game…"
|
||||||
};
|
};
|
||||||
@@ -139,6 +327,7 @@ pub fn GamePage(id: String) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||||
Some(g) => table_view(g, on_hand_card, selected, now),
|
Some(g) => table_view(g, on_hand_card, selected, now),
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-3
@@ -4,7 +4,7 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use futures::channel::mpsc;
|
use futures::channel::mpsc;
|
||||||
use futures::{SinkExt, StreamExt};
|
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 wasm_bindgen_futures::spawn_local;
|
||||||
|
|
||||||
use crate::model::ServerMessage;
|
use crate::model::ServerMessage;
|
||||||
@@ -53,10 +53,14 @@ impl GameSocket {
|
|||||||
/// Open the websocket for `game_id` and forward parsed server messages to
|
/// Open the websocket for `game_id` and forward parsed server messages to
|
||||||
/// `on_message`. Returns the socket handle, or `None` if the connection
|
/// `on_message`. Returns the socket handle, or `None` if the connection
|
||||||
/// could not be created.
|
/// 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(
|
pub fn connect(
|
||||||
game_id: &str,
|
game_id: &str,
|
||||||
on_message: impl Fn(ServerMessage) + 'static,
|
on_message: impl Fn(ServerMessage) + 'static,
|
||||||
on_close: impl Fn() + 'static,
|
on_close: impl Fn(Option<u16>) + 'static,
|
||||||
) -> Option<GameSocket> {
|
) -> Option<GameSocket> {
|
||||||
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
||||||
let (mut write, mut read) = ws.split();
|
let (mut write, mut read) = ws.split();
|
||||||
@@ -72,6 +76,7 @@ pub fn connect(
|
|||||||
});
|
});
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
|
let mut close_code = None;
|
||||||
while let Some(msg) = read.next().await {
|
while let Some(msg) = read.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(Message::Text(text)) => {
|
Ok(Message::Text(text)) => {
|
||||||
@@ -80,10 +85,14 @@ pub fn connect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Message::Bytes(_)) => {}
|
Ok(Message::Bytes(_)) => {}
|
||||||
|
Err(WebSocketError::ConnectionClose(e)) => {
|
||||||
|
close_code = Some(e.code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
on_close();
|
on_close(close_code);
|
||||||
});
|
});
|
||||||
|
|
||||||
Some(GameSocket {
|
Some(GameSocket {
|
||||||
|
|||||||
@@ -418,6 +418,27 @@ table.matches td.lost {
|
|||||||
margin-left: 0.25rem;
|
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 ---------- */
|
/* ---------- overlays ---------- */
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user