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>
417 lines
12 KiB
Markdown
417 lines
12 KiB
Markdown
# 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`:
|
|
|
|
```python
|
|
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 seconds
|
|
- `ping_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`:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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:
|
|
1. Increase expiration timeout
|
|
2. Disable expiration task
|
|
3. 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
|