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