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

+19 -10
View File
@@ -289,17 +289,26 @@ instance and running many behave identically:
to `room:*`; each message it receives is handed to its own local
`ConnectionManager.broadcast()`. One instance just talks to itself
through Redis, so there's no separate single-instance code path.
- `app/ws/presence.py` (`Presence`) — a Redis hash per room
(`presence:{room_id}`, field = user ID, value = connection refcount) is
the cross-instance answer to "is this member connected *anywhere* right
now," which is what the Phase 4 offline-push check uses instead of the
local `ConnectionManager`. Refcounted so a user connected from two tabs
(or two instances) isn't marked offline until every connection closes.
- `app/ws/presence.py` (`Presence`) — a Redis sorted set per room
(`presence:v2:room:{id}`, one member per *connection*, `<user_id>:<conn_id>`,
score = expiry time) is the cross-instance answer to "is this member
connected *anywhere* right now," which is what the Phase 4 offline-push
check uses instead of the local `ConnectionManager`. A user is connected
while any of their entries is unexpired, so two tabs (or two instances)
don't mark them offline until every connection is gone. `GlobalPresence`
(`presence:v2:global`) and `FocusPresence` (`presence:v2:unfocused`) use the
same store, `app/ws/expiring_presence.py` (`ExpiringPresenceSet`).
Known limitation: `Presence` has no heartbeat/TTL, so a hard process crash
(not a clean disconnect) leaks that connection's increment forever — same
category of simplification as the "no server-side session revocation" note
below.
Presence entries expire (#77) so nothing can be stuck online. A connection
renews its entries (90s TTL) whenever the client sends a frame — the frontend
answers the server's 30s `ping` with a `pong`, which is exactly that — and
the server closes a human client's socket (code 4408) after 90s of silence.
Bot (API-token) connections don't speak the heartbeat, so the server renews
their entries itself while their handler is alive. An expiry has no
disconnect event to announce it, so `app/ws/presence_sweeper.py` runs on every
worker (a Redis lock makes it one sweep per 15s overall), prunes expired
entries, and broadcasts the offline transition. The pre-#77 `presence:*`
refcount hashes are deleted at startup.
## Push notifications (Phase 4)