Fix frontend/server round-state desync bugs

- Add a request timeout (AbortController) to the frontend's call() helper, so a
  hung server request no longer freezes the entire polling chain silently.
- Add GET /users/me/last-round-result: a durable, DB-backed fallback for the
  round outcome, since /rounds/current drops winner_user_id the instant a
  round flips from "paying_out" to "closed" — a backgrounded tab or a missed
  poll could otherwise mean a player never learns whether they won.
- Refresh the balance display when a win is revealed (live or via the new
  backstop), instead of leaving the pre-payout balance on screen.
- Guard refreshRound() with a session-epoch counter so an in-flight request
  from a previous login can't re-arm the poll loop after logout, which
  previously produced a duplicate "zombie" polling chain.
This commit is contained in:
2026-07-23 08:53:23 +02:00
parent ad71000777
commit f822911128
3 changed files with 113 additions and 2 deletions
+41 -1
View File
@@ -1,10 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user from app.auth.dependencies import get_current_user
from app.auth.security import hash_password, verify_password from app.auth.security import hash_password, verify_password
from app.db.models import User from app.db.models import Round, RoundParticipant, User
from app.db.session import get_session from app.db.session import get_session
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -52,3 +53,42 @@ async def change_password(
user.password_hash = hash_password(body.new_password) user.password_hash = hash_password(body.new_password)
await session.commit() await session.commit()
class LastRoundResultResponse(BaseModel):
round_id: int | None = None
won: bool = False
amount_sats: int | None = None
@router.get("/me/last-round-result", response_model=LastRoundResultResponse)
async def last_round_result(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> LastRoundResultResponse:
"""The most recent *closed* round this user participated in, with its outcome.
Deliberately independent of /rounds/current: that endpoint only exposes
winner_user_id while the round is "paying_out", and drops it entirely once
the round flips to "closed" (see rounds/service.get_active_round). A client
that misses that narrow window (backgrounded tab, missed poll, page loaded
late) would otherwise never learn the outcome of a round it bet in. This
endpoint reads the durable DB record instead, so the frontend can always
catch up regardless of polling timing."""
row = await session.execute(
select(Round)
.join(RoundParticipant, RoundParticipant.round_id == Round.id)
.where(RoundParticipant.user_id == user.id, Round.status == "closed")
.order_by(Round.id.desc())
.limit(1)
)
round_ = row.scalar_one_or_none()
if round_ is None:
return LastRoundResultResponse()
won = round_.winner_user_id == user.id
return LastRoundResultResponse(
round_id=round_.id,
won=won,
amount_sats=round_.winner_amount_sats if won else None,
)
+71 -1
View File
@@ -263,10 +263,26 @@ async function withLoading(button, label, fn) {
} }
} }
const REQUEST_TIMEOUT_MS = 15000;
// Without a timeout, a single request that never resolves (server-side hang —
// stuck DB session, unresponsive Electrum connection...) would stall the whole
// sequential polling chain forever: the UI just freezes on whatever was last
// rendered, with no error and no "connessione persa" (that only fires on a
// rejected fetch, never on one that's merely stuck).
async function call(method, path, body) { async function call(method, path, body) {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token; if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let res;
try {
res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal });
} catch (e) {
throw new Error(e.name === 'AbortError' ? 'Richiesta al server scaduta.' : e.message);
} finally {
clearTimeout(timeoutId);
}
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText); if (!res.ok) throw new Error(data.detail || res.statusText);
return data; return data;
@@ -286,6 +302,12 @@ function switchPanel(name) {
} }
} }
// Bumped on every logout/login so an in-flight refreshRound() started under a
// previous session can detect it's now stale — a fetch can still be awaiting
// its response after logout() clears the timeout-based poll chain, and without
// this guard it would re-arm scheduleNextRoundPoll() and resurrect a "zombie"
// dashboard poll running in parallel with the logged-out chain-only poll.
let sessionEpoch = 0;
let roundCloseAt = null; let roundCloseAt = null;
let serverTimeOffsetMs = 0; // serverNow - clientNow, so every client's countdown agrees regardless of local clock skew let serverTimeOffsetMs = 0; // serverNow - clientNow, so every client's countdown agrees regardless of local clock skew
function serverNow() { return new Date(Date.now() + serverTimeOffsetMs); } function serverNow() { return new Date(Date.now() + serverTimeOffsetMs); }
@@ -297,6 +319,7 @@ let roundRequestSeq = 0;
let roundAppliedSeq = 0; let roundAppliedSeq = 0;
let roundTimerInterval = null; let roundTimerInterval = null;
let roundPollTimeout = null; let roundPollTimeout = null;
let lastResultInterval = null;
const ROUND_STATUS_LABELS = { const ROUND_STATUS_LABELS = {
open: 'aperto', open: 'aperto',
@@ -411,9 +434,44 @@ document.addEventListener('visibilitychange', () => {
refreshChainStatusOnly(); refreshChainStatusOnly();
} else if (token) { } else if (token) {
refreshRound(); refreshRound();
checkLastRoundResult();
} }
}); });
const LAST_SEEN_RESULT_KEY = 'plm_last_seen_result_round_id';
function markResultSeen(roundId) {
localStorage.setItem(LAST_SEEN_RESULT_KEY, String(roundId));
}
// Backstop for the live reveal in refreshRound(): that one only works if a poll
// happens to land while the round is still "paying_out" (winner_user_id is
// dropped from /rounds/current the instant the round flips to "closed" — see
// rounds/service.get_active_round). A backgrounded tab, a missed poll, or a
// late page load can miss that window entirely, in which case the live path
// never fires and the player would otherwise never learn the outcome. This
// reads GET /users/me/last-round-result, which reports the durable DB record
// instead of an ephemeral snapshot, so it always catches up eventually.
async function checkLastRoundResult() {
if (!token) return;
let data;
try {
data = await call('GET', '/users/me/last-round-result');
} catch (e) {
return; // silent — this is a backstop, refreshRound()'s own error handling already covers the primary path
}
if (data.round_id == null) return;
if (String(data.round_id) === localStorage.getItem(LAST_SEEN_RESULT_KEY)) return; // already surfaced (live or backstop)
markResultSeen(data.round_id);
if (data.won) {
const won = data.amount_sats / SATS_PER_PLM;
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
refreshMe();
} else {
toast('Round #' + data.round_id + ' concluso: non hai vinto questa volta.', 'info');
}
}
// Per round_id: when we first saw it enter a drawing status (client clock), and // Per round_id: when we first saw it enter a drawing status (client clock), and
// whether the win/lose result has already been shown for it. Local-only state, // whether the win/lose result has already been shown for it. Local-only state,
// not persisted — a page reload just re-derives it from the next poll. // not persisted — a page reload just re-derives it from the next poll.
@@ -471,8 +529,10 @@ function showNormalState() {
async function refreshRound() { async function refreshRound() {
const seq = ++roundRequestSeq; const seq = ++roundRequestSeq;
const epoch = sessionEpoch;
try { try {
const data = await call('GET', '/rounds/current'); const data = await call('GET', '/rounds/current');
if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight
if (seq < roundAppliedSeq) return; // a newer refreshRound() call already applied its result if (seq < roundAppliedSeq) return; // a newer refreshRound() call already applied its result
roundAppliedSeq = seq; roundAppliedSeq = seq;
noteFetchOutcome(true); noteFetchOutcome(true);
@@ -505,10 +565,12 @@ async function refreshRound() {
if (data.winner_user_id != null && elapsedMs >= minMs && !revealedRounds.has(data.round_id)) { if (data.winner_user_id != null && elapsedMs >= minMs && !revealedRounds.has(data.round_id)) {
revealedRounds.add(data.round_id); revealedRounds.add(data.round_id);
activeResultRoundId = data.round_id; activeResultRoundId = data.round_id;
markResultSeen(data.round_id);
if (myUserId != null && data.winner_user_id === myUserId) { if (myUserId != null && data.winner_user_id === myUserId) {
const won = (data.winner_amount_sats / SATS_PER_PLM); const won = (data.winner_amount_sats / SATS_PER_PLM);
showResultState('🎉 Hai vinto! +' + won + ' PLM', 'win'); showResultState('🎉 Hai vinto! +' + won + ' PLM', 'win');
toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success'); toast('Hai vinto il round #' + data.round_id + '! +' + won + ' PLM', 'success');
refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount
} else { } else {
showResultState('Non hai vinto questa volta.', 'lose'); showResultState('Non hai vinto questa volta.', 'lose');
} }
@@ -536,6 +598,7 @@ async function refreshRound() {
scheduleNextRoundPoll(isDrawing); scheduleNextRoundPoll(isDrawing);
} catch (e) { } catch (e) {
if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight
noteFetchOutcome(false); noteFetchOutcome(false);
scheduleNextRoundPoll(false); scheduleNextRoundPoll(false);
} }
@@ -547,6 +610,7 @@ function scheduleNextRoundPoll(fast) {
} }
function showDashboard() { function showDashboard() {
sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login
stopChainOnlyPolling(); stopChainOnlyPolling();
document.getElementById('landing-hero').classList.add('hidden'); document.getElementById('landing-hero').classList.add('hidden');
document.getElementById('auth-section').classList.add('hidden'); document.getElementById('auth-section').classList.add('hidden');
@@ -557,6 +621,9 @@ function showDashboard() {
document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address); document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address);
refreshMe(); refreshMe();
refreshRound(); refreshRound();
checkLastRoundResult();
clearInterval(lastResultInterval);
lastResultInterval = setInterval(checkLastRoundResult, 20000);
clearInterval(roundTimerInterval); clearInterval(roundTimerInterval);
roundTimerInterval = setInterval(updateRoundTimer, 1000); roundTimerInterval = setInterval(updateRoundTimer, 1000);
} }
@@ -606,6 +673,7 @@ async function login() {
} }
function resetToLoggedOutUI() { function resetToLoggedOutUI() {
sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving
token = username = address = null; token = username = address = null;
myUserId = null; myUserId = null;
activeResultRoundId = null; activeResultRoundId = null;
@@ -613,6 +681,8 @@ function resetToLoggedOutUI() {
for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key]; for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key];
clearInterval(roundTimerInterval); clearInterval(roundTimerInterval);
clearTimeout(roundPollTimeout); clearTimeout(roundPollTimeout);
clearInterval(lastResultInterval);
lastResultInterval = null;
document.getElementById('app-navbar').classList.add('hidden'); document.getElementById('app-navbar').classList.add('hidden');
document.getElementById('dashboard-section').classList.add('hidden'); document.getElementById('dashboard-section').classList.add('hidden');
document.getElementById('auth-section').classList.remove('hidden'); document.getElementById('auth-section').classList.remove('hidden');
+1
View File
@@ -246,6 +246,7 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
} }
.toast.success { background: var(--color-success-bg); color: var(--color-success); } .toast.success { background: var(--color-success-bg); color: var(--color-success); }
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); } .toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
.toast.info { background: var(--color-surface-inset); color: var(--color-foreground); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
/* --- landing hero (shown only when logged out) --- */ /* --- landing hero (shown only when logged out) --- */