Expire presence so users can't get stuck online (#77)

Presence was a per-user refcount in Redis, decremented only when a
connection's cleanup ran. Anything that ended a connection without
cleanup (a crash, an unclean restart, a laptop asleep with the page
frozen) leaked its count forever, so those users never showed offline.
Production had 19 counts for 2 live connections.

Presence is now one expiring entry per connection (90s TTL) for global,
per-room and focus presence, in new presence:v2:* keys. Clients renew
theirs by sending any frame, which the existing 30s ping/pong already
does. The server closes a human connection that sends nothing for 90s
(code 4408), which catches a frozen page whose browser still answers
transport pings. Bot connections can't be expected to answer pings, so
the server renews theirs itself while the handler is alive.

An expiry has no disconnect event to announce it, so a sweeper on each
worker (one per 15s overall, via a Redis lock) prunes expired entries
and broadcasts the offline transition. The old presence:* hashes are
deleted at startup.

Tests cover the expiry store, the sweeper, and the websocket cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ksmithandClaude Sonnet 5.5 committed 2026-10-13 19:21:39 -06:00
1 parent 7ff7f2ec0e
commit dda85a2b78
11 files changed
+843 -97

No files matched your search

+21 -3
View File
@@ -1,5 +1,6 @@
import asyncio
import contextlib
import logging
import pathlib
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -11,6 +12,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.database import async_session_factory
from app.routers import (
admin,
auth,
@@ -31,6 +33,9 @@ from app.ws.connection_manager import ConnectionManager
from app.ws.focus_presence import FocusPresence
from app.ws.global_presence import GlobalPresence
from app.ws.presence import Presence
from app.ws.presence_sweeper import purge_legacy_presence, run_presence_sweeper
logger = logging.getLogger(__name__)
# backend/app/main.py -> backend/ -> repo root -- matches both the local
# monorepo layout and the production layout (/srv/ds-chat/backend,
@@ -65,11 +70,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# restarts on failure instead.
listener_task = asyncio.create_task(app.state.broadcaster.run_forever())
# #77: presence entries now expire, and an expiry has no disconnect event
# to announce it -- this loop is what notices. The purge clears the old
# refcount hashes (and the counts that had leaked into them); it's
# best-effort, since Redis being down at startup shouldn't stop the app.
try:
await purge_legacy_presence(app.state.redis)
except Exception:
logger.warning("Could not purge legacy presence keys", exc_info=True)
sweeper_task = asyncio.create_task(run_presence_sweeper(app))
yield
listener_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await listener_task
for task in (sweeper_task, listener_task):
task.cancel()
for task in (sweeper_task, listener_task):
with contextlib.suppress(asyncio.CancelledError):
await task
await app.state.redis.aclose()
@@ -85,6 +102,7 @@ def create_app() -> FastAPI:
)
app.state.connection_manager = ConnectionManager()
app.state.session_factory = async_session_factory
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
app.state.presence = Presence(app.state.redis)
app.state.global_presence = GlobalPresence(app.state.redis)