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 pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
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
router = APIRouter(prefix="/users", tags=["users"])
@@ -52,3 +53,42 @@ async def change_password(
user.password_hash = hash_password(body.new_password)
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) {
const headers = { 'Content-Type': 'application/json' };
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(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
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 serverTimeOffsetMs = 0; // serverNow - clientNow, so every client's countdown agrees regardless of local clock skew
function serverNow() { return new Date(Date.now() + serverTimeOffsetMs); }
@@ -297,6 +319,7 @@ let roundRequestSeq = 0;
let roundAppliedSeq = 0;
let roundTimerInterval = null;
let roundPollTimeout = null;
let lastResultInterval = null;
const ROUND_STATUS_LABELS = {
open: 'aperto',
@@ -411,9 +434,44 @@ document.addEventListener('visibilitychange', () => {
refreshChainStatusOnly();
} else if (token) {
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
// 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.
@@ -471,8 +529,10 @@ function showNormalState() {
async function refreshRound() {
const seq = ++roundRequestSeq;
const epoch = sessionEpoch;
try {
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
roundAppliedSeq = seq;
noteFetchOutcome(true);
@@ -505,10 +565,12 @@ async function refreshRound() {
if (data.winner_user_id != null && elapsedMs >= minMs && !revealedRounds.has(data.round_id)) {
revealedRounds.add(data.round_id);
activeResultRoundId = data.round_id;
markResultSeen(data.round_id);
if (myUserId != null && data.winner_user_id === myUserId) {
const won = (data.winner_amount_sats / SATS_PER_PLM);
showResultState('🎉 Hai vinto! +' + won + ' PLM', 'win');
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 {
showResultState('Non hai vinto questa volta.', 'lose');
}
@@ -536,6 +598,7 @@ async function refreshRound() {
scheduleNextRoundPoll(isDrawing);
} catch (e) {
if (epoch !== sessionEpoch) return; // session ended (or a new one started) while this was in flight
noteFetchOutcome(false);
scheduleNextRoundPoll(false);
}
@@ -547,6 +610,7 @@ function scheduleNextRoundPoll(fast) {
}
function showDashboard() {
sessionEpoch++; // invalidate any dashboard poll chain left over from a previous login
stopChainOnlyPolling();
document.getElementById('landing-hero').classList.add('hidden');
document.getElementById('auth-section').classList.add('hidden');
@@ -557,6 +621,9 @@ function showDashboard() {
document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address);
refreshMe();
refreshRound();
checkLastRoundResult();
clearInterval(lastResultInterval);
lastResultInterval = setInterval(checkLastRoundResult, 20000);
clearInterval(roundTimerInterval);
roundTimerInterval = setInterval(updateRoundTimer, 1000);
}
@@ -606,6 +673,7 @@ async function login() {
}
function resetToLoggedOutUI() {
sessionEpoch++; // invalidate any refreshRound() still in flight from the dashboard we're leaving
token = username = address = null;
myUserId = null;
activeResultRoundId = null;
@@ -613,6 +681,8 @@ function resetToLoggedOutUI() {
for (const key of Object.keys(drawStartedAt)) delete drawStartedAt[key];
clearInterval(roundTimerInterval);
clearTimeout(roundPollTimeout);
clearInterval(lastResultInterval);
lastResultInterval = null;
document.getElementById('app-navbar').classList.add('hidden');
document.getElementById('dashboard-section').classList.add('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.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); } }
/* --- landing hero (shown only when logged out) --- */