fix(presence): reset stale users.status on boot; drop REST-login online write

users.status was only flipped back to offline by the WebSocket disconnect
path (5s grace timer in ConnectionManager). Process exits (deploy/crash/OOM)
lose those in-memory timers, freezing any non-offline row at its last value
and making the user appear permanently online to friends and space co-members.
Confirmed in production on the Pi instance: a user appeared online for ~3
days with no live socket.

Add resetStalePresenceOnBoot() in utils/presenceBoot.ts and call it from
index.ts after getDb()/seedDatabase() and before WebSocket route registration.
The reset is federation-safe: it only updates rows where home_instance IS
NULL (replicated stubs are projections of remote presence and must not be
stomped) and is_deleted = 0 (tombstoned users are excluded from broadcasts).

Also remove the redundant status='online' write from POST /api/auth/login.
A successful REST login does not imply a live socket; the WS auth handshake
is the single source of truth. Login alone could otherwise produce the same
stuck-online row when a client logs in and never establishes a WS.

Tests cover: locally-homed online/idle/dnd reset, replicated rows untouched,
tombstoned rows untouched, idempotence, mixed populations.

Updates docs/systems/activity-presence.md (Connect/Disconnect Flow, new Boot
Reset section) and docs/systems/auth.md (login no longer mutates status).
This commit is contained in:
Jannis Braun
2026-04-27 00:35:36 +02:00
parent b698ded47d
commit f17c46c77f
6 changed files with 289 additions and 12 deletions
+50
View File
@@ -0,0 +1,50 @@
import { and, isNull, ne, eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
/**
* Reset orphaned presence state at server boot.
*
* `users.status` is only flipped back to `'offline'` by the WebSocket
* disconnect path (`ConnectionManager.finalizeDisconnect` after a 5s grace
* timer). When the server process exits — deploy, crash, OOM, kill — those
* in-memory grace timers are lost and any rows currently set to `'online'`,
* `'idle'`, or `'dnd'` stay frozen at that value forever, causing users to
* appear permanently online to friends and space co-members until they next
* connect.
*
* At boot, the in-memory `ConnectionManager` is empty by construction, so
* any non-`offline` status row is by definition stale and safe to reset.
*
* Federation safety:
* - The `users` table contains replicated user stubs for users whose home
* instance is elsewhere (`home_instance` non-null). Their `status` is a
* projection of remote presence, broadcast to us by their home instance,
* and is NOT a function of our local WebSocket state. We must not touch
* replicated rows — only reset rows where `home_instance IS NULL`.
* - Soft-deleted (tombstoned) users have `is_deleted = 1` and are excluded
* from presence broadcasts already; leave their stored status alone.
*
* Called once during server boot, after `getDb()` succeeds and before the
* WebSocket handler is registered. Idempotent — re-running has no effect
* once all locally-homed users are `'offline'`.
*
* @returns Number of rows reset (for logging / test assertions).
*/
export function resetStalePresenceOnBoot(): number {
const db = getDb();
const result = db.update(schema.users)
.set({ status: 'offline' })
.where(and(
isNull(schema.users.homeInstance),
eq(schema.users.isDeleted, 0),
ne(schema.users.status, 'offline'),
))
.run();
// better-sqlite3's RunResult exposes `changes`; drizzle passes it through.
const changes = (result as { changes?: number }).changes ?? 0;
if (changes > 0) {
console.log(`[presenceBoot] Reset ${changes} stale user status row(s) to 'offline'`);
}
return changes;
}