Frontend UX improvements: - Single-click Discord OAuth from home page (no intermediate /auth page) - Auto-redirect authenticated users from home to /games - Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout - Games page now has proper card container with shadow/border styling - Layout header includes working logout with API cookie clearing Games list enhancements: - Display team names (lname) instead of just team IDs - Show current score for each team - Show inning indicator (Top/Bot X) for active games - Responsive header with wrapped buttons on mobile Backend improvements: - Added team caching to SbaApiClient (1-hour TTL) - Enhanced GameListItem with team names, scores, inning data - Games endpoint now enriches response with SBA API team data Docker optimizations: - Optimized Dockerfile using --chown flag on COPY (faster than chown -R) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
12 KiB
12 KiB
Plan 007: Session Expiration
Priority: HIGH Effort: 1-2 hours Status: NOT STARTED Risk Level: MEDIUM - Zombie connections
Problem Statement
WebSocket sessions persist indefinitely after network failures. There's no:
- Ping timeout configuration for Socket.io
- Session expiration tracking
- Cleanup of zombie connections
Zombie connections accumulate, causing:
- Memory leaks
- Stale user presence in games
- Inaccurate connection counts
Impact
- Memory: Unbounded connection tracking growth
- UX: Stale players shown as "connected"
- Performance: Broadcasting to dead connections
Files to Modify
| File | Action |
|---|---|
backend/app/main.py |
Configure Socket.io timeouts |
backend/app/websocket/connection_manager.py |
Add session expiration |
backend/app/websocket/handlers.py |
Handle heartbeat events |
Implementation Steps
Step 1: Configure Socket.io Timeouts (15 min)
Update backend/app/main.py:
import socketio
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins="*",
# Timeout configuration
ping_timeout=30, # Wait 30s for pong before disconnect
ping_interval=25, # Send ping every 25s
max_http_buffer_size=1_000_000, # 1MB max message size
logger=True,
engineio_logger=True
)
Explanation:
ping_interval=25: Server sends ping every 25 secondsping_timeout=30: Client must respond within 30 seconds- Total: Connection dies after 55 seconds of no response
Step 2: Add Session Tracking (30 min)
Update backend/app/websocket/connection_manager.py:
import pendulum
from dataclasses import dataclass
from uuid import UUID
import asyncio
@dataclass
class SessionInfo:
"""Tracks WebSocket session metadata."""
user_id: int | None
connected_at: pendulum.DateTime
last_activity: pendulum.DateTime
games: set[UUID]
ip_address: str | None = None
class ConnectionManager:
def __init__(self):
self._sessions: dict[str, SessionInfo] = {} # sid -> SessionInfo
self._user_sessions: dict[int, set[str]] = {} # user_id -> sids
self._game_sessions: dict[UUID, set[str]] = {} # game_id -> sids
self._expiration_task: asyncio.Task | None = None
async def connect(self, sid: str, user_id: int | None = None, ip_address: str | None = None):
"""Register new connection."""
now = pendulum.now("UTC")
self._sessions[sid] = SessionInfo(
user_id=user_id,
connected_at=now,
last_activity=now,
games=set(),
ip_address=ip_address
)
if user_id:
if user_id not in self._user_sessions:
self._user_sessions[user_id] = set()
self._user_sessions[user_id].add(sid)
logger.info(f"Session connected: {sid} (user={user_id})")
async def disconnect(self, sid: str):
"""Clean up disconnected session."""
session = self._sessions.pop(sid, None)
if session:
# Remove from user tracking
if session.user_id and session.user_id in self._user_sessions:
self._user_sessions[session.user_id].discard(sid)
if not self._user_sessions[session.user_id]:
del self._user_sessions[session.user_id]
# Remove from game rooms
for game_id in session.games:
if game_id in self._game_sessions:
self._game_sessions[game_id].discard(sid)
logger.info(f"Session disconnected: {sid} (was connected {session.connected_at})")
async def update_activity(self, sid: str):
"""Update last activity timestamp for session."""
if sid in self._sessions:
self._sessions[sid].last_activity = pendulum.now("UTC")
async def get_session(self, sid: str) -> SessionInfo | None:
"""Get session info."""
return self._sessions.get(sid)
async def get_user_id(self, sid: str) -> int | None:
"""Get user ID for session."""
session = self._sessions.get(sid)
return session.user_id if session else None
async def join_game(self, sid: str, game_id: UUID):
"""Add session to game room."""
if sid in self._sessions:
self._sessions[sid].games.add(game_id)
if game_id not in self._game_sessions:
self._game_sessions[game_id] = set()
self._game_sessions[game_id].add(sid)
await self.update_activity(sid)
async def leave_game(self, sid: str, game_id: UUID):
"""Remove session from game room."""
if sid in self._sessions:
self._sessions[sid].games.discard(game_id)
if game_id in self._game_sessions:
self._game_sessions[game_id].discard(sid)
async def expire_inactive_sessions(self, timeout_seconds: int = 300):
"""
Expire sessions with no activity for timeout period.
Called periodically by background task.
"""
now = pendulum.now("UTC")
expired = []
for sid, session in list(self._sessions.items()):
inactive_seconds = (now - session.last_activity).total_seconds()
if inactive_seconds > timeout_seconds:
expired.append(sid)
logger.warning(f"Expiring inactive session: {sid} (inactive {inactive_seconds}s)")
for sid in expired:
await self.disconnect(sid)
# Notify Socket.io to close the connection
try:
await sio.disconnect(sid)
except Exception as e:
logger.debug(f"Error disconnecting expired session {sid}: {e}")
if expired:
logger.info(f"Expired {len(expired)} inactive sessions")
return expired
def get_stats(self) -> dict:
"""Return connection statistics."""
return {
"total_sessions": len(self._sessions),
"unique_users": len(self._user_sessions),
"active_games": len(self._game_sessions),
"sessions_per_game": {
str(gid): len(sids) for gid, sids in self._game_sessions.items()
}
}
# Global instance
manager = ConnectionManager()
Step 3: Start Expiration Background Task (15 min)
Update backend/app/main.py:
from app.websocket.connection_manager import manager
async def session_expiration_task():
"""Background task to expire inactive sessions."""
while True:
try:
await asyncio.sleep(60) # Check every minute
await manager.expire_inactive_sessions(timeout_seconds=300) # 5 min timeout
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Session expiration error: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Start session expiration task
expiration_task = asyncio.create_task(session_expiration_task())
yield
# Stop task
expiration_task.cancel()
try:
await expiration_task
except asyncio.CancelledError:
pass
Step 4: Update Handlers to Track Activity (20 min)
Update backend/app/websocket/handlers.py:
from app.websocket.connection_manager import manager
@sio.event
async def connect(sid, environ, auth):
"""Handle new connection."""
# Extract user info from auth
user_id = None
if auth and "token" in auth:
user_id = await extract_user_id_from_token(auth["token"])
# Extract IP address
ip_address = environ.get("REMOTE_ADDR")
await manager.connect(sid, user_id=user_id, ip_address=ip_address)
logger.info(f"Client connected: {sid}")
@sio.event
async def disconnect(sid):
"""Handle disconnection."""
await manager.disconnect(sid)
logger.info(f"Client disconnected: {sid}")
# Update activity on any action
@sio.event
async def submit_defensive_decision(sid, data):
await manager.update_activity(sid)
# ... existing logic ...
@sio.event
async def submit_offensive_decision(sid, data):
await manager.update_activity(sid)
# ... existing logic ...
@sio.event
async def roll_dice(sid, data):
await manager.update_activity(sid)
# ... existing logic ...
# Add explicit heartbeat handler (optional, for client-initiated keepalive)
@sio.event
async def heartbeat(sid, data):
"""Client-initiated heartbeat to keep session alive."""
await manager.update_activity(sid)
await sio.emit("heartbeat_ack", {"timestamp": pendulum.now("UTC").isoformat()}, to=sid)
Step 5: Add Health Endpoint (10 min)
Update backend/app/api/routes.py:
from app.websocket.connection_manager import manager
@router.get("/health/connections")
async def connection_health():
"""Return WebSocket connection statistics."""
stats = manager.get_stats()
return {
"status": "healthy",
**stats
}
Step 6: Write Tests (30 min)
Create backend/tests/unit/websocket/test_session_expiration.py:
import pytest
import pendulum
from uuid import uuid4
from app.websocket.connection_manager import ConnectionManager, SessionInfo
class TestSessionExpiration:
"""Tests for session expiration."""
@pytest.fixture
def manager(self):
return ConnectionManager()
@pytest.mark.asyncio
async def test_connect_creates_session(self, manager):
"""Connect creates session with correct info."""
await manager.connect("sid1", user_id=123)
session = await manager.get_session("sid1")
assert session is not None
assert session.user_id == 123
@pytest.mark.asyncio
async def test_disconnect_removes_session(self, manager):
"""Disconnect removes session."""
await manager.connect("sid1", user_id=123)
await manager.disconnect("sid1")
session = await manager.get_session("sid1")
assert session is None
@pytest.mark.asyncio
async def test_activity_updates_timestamp(self, manager):
"""Activity updates last_activity timestamp."""
await manager.connect("sid1")
original = manager._sessions["sid1"].last_activity
await asyncio.sleep(0.01)
await manager.update_activity("sid1")
updated = manager._sessions["sid1"].last_activity
assert updated > original
@pytest.mark.asyncio
async def test_expire_removes_inactive_sessions(self, manager):
"""Inactive sessions are expired."""
await manager.connect("sid1")
# Make session old
manager._sessions["sid1"].last_activity = pendulum.now("UTC").subtract(minutes=10)
expired = await manager.expire_inactive_sessions(timeout_seconds=300)
assert "sid1" in expired
assert "sid1" not in manager._sessions
@pytest.mark.asyncio
async def test_active_sessions_not_expired(self, manager):
"""Active sessions are not expired."""
await manager.connect("sid1")
await manager.update_activity("sid1")
expired = await manager.expire_inactive_sessions(timeout_seconds=300)
assert "sid1" not in expired
assert "sid1" in manager._sessions
@pytest.mark.asyncio
async def test_join_game_tracked(self, manager):
"""Joining game updates session and game tracking."""
await manager.connect("sid1")
game_id = uuid4()
await manager.join_game("sid1", game_id)
assert game_id in manager._sessions["sid1"].games
assert "sid1" in manager._game_sessions[game_id]
Verification Checklist
- Socket.io ping/pong configured
- Sessions track last activity
- Inactive sessions are expired (5 min default)
- Background task runs without errors
- Health endpoint shows connection stats
- Tests pass
Configuration Options
| Setting | Default | Description |
|---|---|---|
ping_interval |
25s | How often to send ping |
ping_timeout |
30s | Max wait for pong |
| Expiration timeout | 300s | Inactivity before expiration |
| Check interval | 60s | How often to check for expired |
Rollback Plan
If issues arise:
- Increase expiration timeout
- Disable expiration task
- Revert Socket.io timeout config
Dependencies
- None (can be implemented independently)
Notes
- Consider sending "about to expire" warning to clients
- May want different timeouts for different game states
- Future: Add reconnection handling with session recovery