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:
+41
-1
@@ -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
@@ -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');
|
||||
|
||||
@@ -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) --- */
|
||||
|
||||
Reference in New Issue
Block a user