2 Commits
Author SHA1 Message Date
woggioni 57c6ffb032 Reconnect the game websocket after connectivity loss
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.
2026-09-18 13:48:34 +08:00
woggioni c10d45523a Fix deadline consumer startup under granian RSGI
The mixin received the event loop from Kaya but ignored it, calling
asyncio.get_running_loop() instead. Under granian RSGI __rsgi_init__ runs
before the loop starts, so that raised RuntimeError and killed the
worker. Use the loop passed to setup(), falling back to the running loop
for the lazy calls from sync_deadline().
2026-09-18 13:48:34 +08:00
4 changed files with 259 additions and 34 deletions
+11 -5
View File
@@ -214,14 +214,20 @@ async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, An
# --- consumer lifecycle -------------------------------------------------------
def ensure_consumer(store: GameStore) -> None:
"""Start the deadline consumer on the running loop if not yet running.
def ensure_consumer(
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
"""Start the deadline consumer on the given (or running) loop if not
yet running.
Called lazily whenever a deadline is enqueued (the ASGI test transport
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
enough) and on application startup.
enough) and on application startup. The explicit ``loop`` matters at
startup: under RSGI granian calls ``setup`` before the loop runs, so
``asyncio.get_running_loop()`` would fail there.
"""
loop = asyncio.get_running_loop()
if loop is None:
loop = asyncio.get_running_loop()
for old in list(_consumers):
if old.is_closed():
_consumers.pop(old, None)
@@ -284,7 +290,7 @@ class DeadlineSchedulerMixin(KayaMixin):
pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
ensure_consumer(self._store)
ensure_consumer(self._store, loop)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
stop_consumer(loop)
+215 -26
View File
@@ -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" } }
}
}
}
}
+12 -3
View File
@@ -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<u16>) + 'static,
) -> Option<GameSocket> {
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 {
+21
View File
@@ -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 {