Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion apps/frontend/src/screens/LiveQuiz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@
const username = location.state?.username;

const [ws, setWs] = useState<WebSocket | null>(null);
const [connectionState, setConnectionState] =
useState<'connected' | 'reconnecting' | 'failed'>('connected');
const [gameState, setGameState] = useState<GameState>('WAITING');
const [participants, setParticipants] = useState<any[]>([]);

Check failure on line 44 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type
const [currentQuestion, setCurrentQuestion] = useState<any>(null);

Check failure on line 45 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type
const [timeLeft, setTimeLeft] = useState(0);
const [correctAnswers, setCorrectAnswers] = useState<string[]>([]);
const [leaderboard, setLeaderboard] = useState<any[]>([]);

Check failure on line 48 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type

const [joinCode, setJoinCode] = useState('');

Expand Down Expand Up @@ -71,10 +73,37 @@

let socket: WebSocket | null = null;
let isMounted = true;

let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// Set when we close a socket ourselves (unmount, or replacing a stale
// one) so onclose knows not to treat it as a dropped connection.
let deliberateClose = false;

const wsHost = import.meta.env.VITE_WS_URL || 'ws://localhost:8080/';

// Exponential backoff with jitter: 1s, 2s, 4s, 8s, capped at 10s. The
// jitter matters at 500 users - without it, a server restart makes every
// client reconnect in the same instant and knock it over again.
const MAX_RECONNECT_ATTEMPTS = 8;
const reconnectDelay = () => {
const base = Math.min(1000 * 2 ** reconnectAttempts, 10_000);
return base + Math.random() * 500;
};

const scheduleReconnect = () => {
if (!isMounted || deliberateClose) return;

if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
setConnectionState('failed');
return;
}

setConnectionState('reconnecting');
const delay = reconnectDelay();
reconnectAttempts++;
reconnectTimer = setTimeout(initWebSocket, delay);
};



const initWebSocket = () => {
Expand All @@ -84,6 +113,9 @@

socket.onopen = () => {

reconnectAttempts = 0;
setConnectionState('connected');

if (!sessionId) {
console.log("Session is not initiated. Try again...")
return;
Expand Down Expand Up @@ -130,6 +162,23 @@
setGameState(GameState.QUESTION);
setQuestionStartTime(Date.now());

break;
// Sent only to a reconnecting client, to put them back on the
// question already in flight instead of a blank screen.
case 'quiz:state-snapshot':
setCurrentQuestion(payload.question);
setCorrectAnswers([]);
setTimeLeft(payload.timeLeft);
// Restoring their pick (or blocking a fresh one when the window
// has closed) is what stops a rejoin scoring the same question
// twice; the server rejects it either way.
setSelectedAnswer(
payload.alreadyAnswered ? (payload.selectedAnswerId ?? '__answered__') : null
);
// Scoring uses Date.now() - questionStartTime, so anchor it to
// the real remaining time rather than the moment they rejoined.
setQuestionStartTime(Date.now() - (payload.question.timeLimit - payload.timeLeft) * 1000);
setGameState(payload.expired ? GameState.RESULTS : GameState.QUESTION);
break;
case 'quiz:timer-tick':
setTimeLeft(payload.timeLeft);
Expand All @@ -150,10 +199,17 @@



// onerror is always followed by onclose, so reconnect is scheduled
// there rather than in both places.
socket.onerror = () => {
socket?.close();
};

socket.onclose = () => {
if (!isMounted || deliberateClose) return;
scheduleReconnect();
};

setWs(socket);
};

Expand All @@ -163,8 +219,13 @@

return () => {
isMounted = false;
deliberateClose = true;
clearTimeout(timeoutId);
if (reconnectTimer) clearTimeout(reconnectTimer);
if (socket) {
// Drop the handler first: closing a socket fires onclose, which would
// otherwise schedule a reconnect for a screen that is going away.
socket.onclose = null;
if (socket.readyState === 0) {
socket.onopen = () => socket?.close();
} else {
Expand All @@ -173,7 +234,7 @@
}
};

}, [sessionId, isOrganizer]);

Check warning on line 237 in apps/frontend/src/screens/LiveQuiz.tsx

View workflow job for this annotation

GitHub Actions / Lint & type-check

React Hook useEffect has missing dependencies: 'navigate', 'participantId', and 'user?.id'. Either include them or remove the dependency array


const handleStart = () => {
Expand Down Expand Up @@ -212,10 +273,43 @@
}


// Rendered above every game state, so a participant always knows the live
// connection dropped rather than silently missing questions.
const ConnectionBanner = () => {
if (connectionState === 'connected') return null;

const failed = connectionState === 'failed';

return (
<div
role="status"
aria-live="polite"
className={`fixed top-0 inset-x-0 z-100 px-4 py-2 text-center text-sm font-medium
${failed ? 'bg-red-600 text-white' : 'bg-amber-500 text-black'}`}
>
{failed ? (
<span>
Connection lost.{' '}
<button
onClick={() => window.location.reload()}
className="underline font-semibold"
>
Reload to rejoin
</button>
</span>
) : (
'Reconnecting…'
)}
</div>
);
};


if (gameState === GameState.WAITING) {

return (
<div className="min-h-screen w-full bg-[#000000]/98 opacity-99">
<ConnectionBanner />
<div className="w-full bg-linear-to-tl from-transparent via-pink-600/10 to-transparent">

<div className="min-h-screen flex flex-col items-center justify-center bg-surface relative overflow-hidden p-6">
Expand Down Expand Up @@ -298,6 +392,7 @@
if (gameState === GameState.STARTING) {
return (
<div className="bg-[#000000]/98 opacity-99 w-full min-h-screen">
<ConnectionBanner />

<div className="w-full bg-linear-to-tl from-transparent via-pink-600/10 to-transparent">

Expand Down Expand Up @@ -325,6 +420,7 @@
return (

<div className="w-full min-h-screen bg-[#000000]/99 opacity-98">
<ConnectionBanner />

<div className="w-full min-h-screen z-50 bg-linear-to-tl from-transparent via-pink-600/10 to-transparent">

Expand Down Expand Up @@ -432,6 +528,7 @@
return (

<div className=" bg-[#000000]/98 opacity-99 min-h-screen">
<ConnectionBanner />

<div className="w-full min-h-screen z-50 bg-linear-to-tl from-transparent via-pink-600/10 to-transparent">

Expand Down Expand Up @@ -492,6 +589,7 @@
return (

<div className="w-full min-h-screen bg-[#000000]/98 opacity-99">
<ConnectionBanner />

<div className="w-full min-h-screen z-50 bg-linear-to-tl from-transparent via-pink-600/10 to-transparent">

Expand Down
41 changes: 26 additions & 15 deletions apps/http-server/src/controllers/sessionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,26 +38,37 @@ export const joinQuizController = async (req: Request, res: Response) => {
}


// find and create a session
let session = quiz.quizSessions[0];
// Session lookup/creation and the participant insert go together: on
// their own, a failed participant insert would leave an empty session
// behind for the quiz.
const { session, participant } = await prisma.$transaction(async (tx) => {
// Re-read inside the transaction rather than trusting the earlier
// read: at 500 simultaneous joins many requests see no session at
// once, and each would otherwise create its own.
let session = quiz.quizSessions[0]
?? await tx.quizSession.findFirst({
where: { quizId: quiz.id, status: { in: ['WAITING', 'IN_PROGRESS'] } },
});

if (!session) {
session = await tx.quizSession.create({
data: {
quizId: quiz.id,
status: 'WAITING'
}
});
}

if (!session) {
session = await prisma.quizSession.create({
// create participnats
const participant = await tx.participant.create({
data: {
quizId: quiz.id,
status: 'WAITING'
username,
sessionId: session.id,
userId: req.userId || null
}
});
}


// create participnats
const participant = await prisma.participant.create({
data: {
username,
sessionId: session.id,
userId: req.userId || null
}
return { session, participant };
});

return res.status(200).json({
Expand Down
30 changes: 29 additions & 1 deletion apps/ws-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,29 @@ export interface Client {
role: 'ORGANIZER' | 'PARTICIPANT';
participantId?: string;
userId?: string;
isAlive: boolean;
}

// A dropped connection (NAT timeout, closed laptop, proxy cutting an idle
// socket) often never fires 'close', so the client would linger in `clients`
// forever and keep inflating participant counts. Ping every 30s and drop
// anything that has not ponged since the previous round.
const HEARTBEAT_INTERVAL_MS = 30_000;

const heartbeat = setInterval(() => {
for (const client of clients) {
if (!client.isAlive) {
client.ws.terminate(); // fires 'close' -> normal cleanup path
continue;
}

client.isAlive = false;
client.ws.ping();
}
}, HEARTBEAT_INTERVAL_MS);

wss.on('close', () => clearInterval(heartbeat));


startSessionSubscriber();

Expand All @@ -31,10 +52,17 @@ wss.on('connection', (ws: WebSocket) => {
const client: Client = {
ws,
sessionId: '',
role: 'PARTICIPANT'
role: 'PARTICIPANT',
isAlive: true
};
clients.add(client);

// Browsers answer a protocol-level ping automatically, so this needs no
// client-side support.
ws.on('pong', () => {
client.isAlive = true;
});


ws.on('message', async (message) => {
try {
Expand Down
25 changes: 15 additions & 10 deletions apps/ws-server/src/utils/endQuizSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@ import { prisma } from "@repo/db";
import { clearSessionCache } from "./cache.js";

export async function endQuizSession(sessionId: string) {
// Mark session as completed
const session = await prisma.quizSession.update({
where: { id: sessionId },
data: { status: 'COMPLETED', endedAt: new Date() },
include: { quiz: true }
});
// Both writes go together: if the second failed on its own, the session
// would read COMPLETED while the quiz still carried a live joinCode, and
// people could keep joining a quiz that had already finished.
await prisma.$transaction(async (tx) => {
// Mark session as completed
const session = await tx.quizSession.update({
where: { id: sessionId },
data: { status: 'COMPLETED', endedAt: new Date() },
include: { quiz: true }
});

// Clear join code and set quiz to COMPLETED so no one can join again
await prisma.quiz.update({
where: { id: session.quizId },
data: { joinCode: null, status: 'COMPLETED' }
// Clear join code and set quiz to COMPLETED so no one can join again
await tx.quiz.update({
where: { id: session.quizId },
data: { joinCode: null, status: 'COMPLETED' }
});
});

await clearSessionCache(sessionId);
Expand Down
Loading
Loading