strat-gameplay-webapp/.claude/plans/007-session-expiration.md
Cal Corum e0c12467b0 CLAUDE: Improve UX with single-click OAuth, enhanced games list, and layout fix
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>
2025-12-05 16:14:00 -06:00

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 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:

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:

  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