From f17c46c77fab8f5db85444745630d80531ce1db0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:35:36 +0200 Subject: [PATCH] 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). --- docs/systems/activity-presence.md | 39 +++- docs/systems/auth.md | 3 +- packages/server/src/index.ts | 9 + packages/server/src/routes/auth.ts | 11 +- .../server/src/utils/presenceBoot.test.ts | 189 ++++++++++++++++++ packages/server/src/utils/presenceBoot.ts | 50 +++++ 6 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 packages/server/src/utils/presenceBoot.test.ts create mode 100644 packages/server/src/utils/presenceBoot.ts diff --git a/docs/systems/activity-presence.md b/docs/systems/activity-presence.md index 0d05a423..f9513f9e 100644 --- a/docs/systems/activity-presence.md +++ b/docs/systems/activity-presence.md @@ -12,6 +12,7 @@ Source files: - `packages/web/src/components/modals/settingsPanels/PrivacyPanel.tsx` — showActivity toggle UI - `packages/server/src/ws/handler.ts` — ConnectionManager (in-memory activity state, rate limiting, disconnect cleanup) - `packages/server/src/ws/events.ts` — handlePresenceUpdate, handleActivityUpdate, validateActivities +- `packages/server/src/utils/presenceBoot.ts` — boot-time reset of orphaned `users.status` rows (federation-safe) - `packages/server/src/routes/users.ts` — REST showActivity toggle with server-side activity clear - `packages/desktop/src/activityDetector.ts` — Process polling, game dictionary matching (boundary: see Desktop section) - `packages/desktop/src/preload.ts` — IPC channel exposure (activity-detected, get-current-activity) @@ -122,16 +123,40 @@ function getPrimaryActivity(activities: Activity[]): Activity | null { The `users.status` column (see database.md) stores the current presence status. Default: `'offline'`. -- **On connect:** Server sets `status = 'online'` in DB (`ws/handler.ts:1344`) -- **On manual change:** Client sends `presence_update` with `status` field; server persists to DB (`ws/events.ts:483`) -- **On disconnect:** After 5s grace period, server sets `status = 'offline'` in DB (`ws/handler.ts:225`) +- **On connect:** Server sets `status = 'online'` in DB at WebSocket auth (`ws/handler.ts`, the line after `authenticated = true`). The REST `/api/auth/login` route does **not** set status — login alone does not imply a live socket; the WS handshake is the single source of truth. +- **On manual change:** Client sends `presence_update` with `status` field; server persists to DB (`ws/events.ts`) +- **On disconnect:** After 5s grace period, server sets `status = 'offline'` in DB (`ws/handler.ts:finalizeDisconnect`) +- **On boot:** Server resets stale rows for locally-homed, non-deleted users (see "Boot Reset" below). + +### Boot Reset (`utils/presenceBoot.ts`) + +`users.status` is only flipped back to `'offline'` by `ConnectionManager.finalizeDisconnect()` after a real WS close + 5s grace timer. Those timers live in process memory, so a server restart (deploy, crash, OOM, kill) loses them and any row currently set to `'online'`, `'idle'`, or `'dnd'` stays frozen at that value forever — making the user appear permanently online to friends and space co-members until they next connect. + +`resetStalePresenceOnBoot()` runs once during server boot in `index.ts`, after `getDb()`/`seedDatabase()` and before WebSocket route registration. It executes a single update: + +``` +UPDATE users + SET status = 'offline' + WHERE home_instance IS NULL + AND is_deleted = 0 + AND status != 'offline' +``` + +Three guards on the WHERE clause: + +1. **`home_instance IS NULL`** — replicated user stubs (federated identities homed elsewhere) have their status projected to us by the home instance via `presence_update` relays, not by our local WS state. Their status must not be touched on our boot. +2. **`is_deleted = 0`** — tombstoned users are excluded from presence broadcasts already; their stored status is left alone as a maintenance courtesy (no behavioral effect either way, but avoids silent rewrites). +3. **`status != 'offline'`** — keeps the operation a no-op once steady-state is reached; `changes` is logged only when non-zero. + +Because the in-memory `ConnectionManager` is empty at boot by construction, no live connection can be misrepresented by this reset. ### Connect/Disconnect Flow -1. **Auth succeeds** → `status` set to `'online'` in DB → `presence_update` broadcast to all user's spaces (excludes self; self gets `ready` payload) -2. **Last socket closes** → 5-second grace period (`scheduleDisconnect`) to allow tab refresh/reconnect -3. **Grace period expires** → `finalizeDisconnect`: sets DB status to `'offline'`, clears in-memory activities, broadcasts `presence_update` with `status: 'offline'` and `activities: []` to all spaces -4. **Reconnect during grace** → `cancelDisconnect` prevents offline broadcast; new connection proceeds normally +1. **Server boot** → `resetStalePresenceOnBoot()` flips any locally-homed, non-deleted `online`/`idle`/`dnd` rows to `offline`. Federated rows untouched. +2. **Auth succeeds** → `status` set to `'online'` in DB → `presence_update` broadcast to all user's spaces (excludes self; self gets `ready` payload) +3. **Last socket closes** → 5-second grace period (`scheduleDisconnect`) to allow tab refresh/reconnect +4. **Grace period expires** → `finalizeDisconnect`: sets DB status to `'offline'`, clears in-memory activities, broadcasts `presence_update` with `status: 'offline'` and `activities: []` to all spaces +5. **Reconnect during grace** → `cancelDisconnect` prevents offline broadcast; new connection proceeds normally ### Presence Broadcast Scope diff --git a/docs/systems/auth.md b/docs/systems/auth.md index a2cf3e73..e5b50248 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -191,8 +191,7 @@ Validates format (same rules as local registration: 3-32 chars, `/^[a-z0-9_]+$/` 5. Verify password via bcrypt 6. **If password invalid AND user is federated:** attempt self-healing (see below) 7. **If password invalid AND user is local:** reject -8. Set user status to `'online'` -9. Sign JWT, return `{ token, user }` +8. Sign JWT, return `{ token, user }`. **Note:** Login does NOT mutate `users.status`. A successful login does not by itself imply a live connection (the client may never establish a WebSocket due to network failure, mobile background, error path); writing `'online'` here would produce a permanently stuck-online row that no disconnect timer cleans up. The WebSocket auth path (`ws/handler.ts`) is the single source of truth for `status = 'online'`. See `docs/systems/activity-presence.md` "Boot Reset" for the mitigation that runs on server start. ### Federation Password Self-Healing diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9a50d082..4782afdc 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -28,6 +28,7 @@ import { federationRoutes } from './routes/federation.js'; import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js'; import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures. import { registerCallRelayHooks } from './ws/events.js'; +import { resetStalePresenceOnBoot } from './utils/presenceBoot.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; @@ -82,6 +83,14 @@ async function main(): Promise { getDb(); await seedDatabase(); + // Reset orphaned `users.status` rows for locally-homed users. The previous + // process's in-memory disconnect timers are gone, so any non-offline row + // is stale by construction. Replicated (federated) rows are skipped — their + // status is a projection of remote presence, not local WS state. Must run + // before WS auth is accepted so the first connection broadcasts the correct + // online transition. See utils/presenceBoot.ts. + resetStalePresenceOnBoot(); + await app.register(authRoutes); await app.register(userRoutes); await app.register(spaceRoutes); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index d2449c3e..445ffa63 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -308,13 +308,18 @@ export async function authRoutes(app: FastifyInstance): Promise { } } - db.update(schema.users).set({ status: 'online' }).where(eq(schema.users.id, user.id)).run(); - + // Note: status='online' is set exclusively by the WebSocket auth path + // (ws/handler.ts). A successful REST /login does not by itself imply a + // live connection — the client may never establish a WS (transient + // network failure, mobile background, error path), which would otherwise + // produce a permanently stuck-online row that no disconnect timer can + // clean up. The user's reported status remains whatever it was; the WS + // handshake will flip it to 'online' once a real socket attaches. const token = signJwt({ userId: user.id, username: user.username }); const response: AuthResponse = { token, - user: sanitizeUser({ ...user, status: 'online' }, true), + user: sanitizeUser(user, true), }; return reply.code(200).send(response); diff --git a/packages/server/src/utils/presenceBoot.test.ts b/packages/server/src/utils/presenceBoot.test.ts new file mode 100644 index 00000000..89daf0f0 --- /dev/null +++ b/packages/server/src/utils/presenceBoot.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from './snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function insertUser(overrides: Partial): string { + const id = overrides.id ?? `u-${Math.random().toString(36).slice(2, 10)}`; + testDb + .insert(schema.users) + .values({ + id, + username: overrides.username ?? `user-${id}`, + passwordHash: overrides.passwordHash ?? 'hash', + status: overrides.status ?? 'offline', + isDeleted: overrides.isDeleted ?? 0, + homeInstance: overrides.homeInstance ?? null, + homeUserId: overrides.homeUserId ?? null, + createdAt: overrides.createdAt ?? Date.now(), + ...overrides, + } as typeof schema.users.$inferInsert) + .run(); + return id; +} + +function getStatus(id: string): string | null { + const row = testDb + .select({ status: schema.users.status }) + .from(schema.users) + .where(eq(schema.users.id, id)) + .get(); + return row?.status ?? null; +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); +}); + +describe('resetStalePresenceOnBoot', () => { + it('resets locally-homed online users to offline', async () => { + const aliceId = insertUser({ username: 'alice', status: 'online' }); + const { resetStalePresenceOnBoot } = await import('./presenceBoot.js'); + + const changed = resetStalePresenceOnBoot(); + + expect(changed).toBe(1); + expect(getStatus(aliceId)).toBe('offline'); + }); + + it('resets locally-homed idle and dnd users to offline', async () => { + const idleId = insertUser({ username: 'idle-user', status: 'idle' }); + const dndId = insertUser({ username: 'dnd-user', status: 'dnd' }); + const { resetStalePresenceOnBoot } = await import('./presenceBoot.js'); + + const changed = resetStalePresenceOnBoot(); + + expect(changed).toBe(2); + expect(getStatus(idleId)).toBe('offline'); + expect(getStatus(dndId)).toBe('offline'); + }); + + it('does not modify replicated (federated) user rows', async () => { + const localId = insertUser({ username: 'local', status: 'online' }); + const remoteOnlineId = insertUser({ + username: 'remote-online', + status: 'online', + homeInstance: 'orbit.example', + homeUserId: 'remote-uid-1', + }); + const remoteIdleId = insertUser({ + username: 'remote-idle', + status: 'idle', + homeInstance: 'nova.example', + homeUserId: 'remote-uid-2', + }); + const remoteDndId = insertUser({ + username: 'remote-dnd', + status: 'dnd', + homeInstance: 'orbit.example', + homeUserId: 'remote-uid-3', + }); + const { resetStalePresenceOnBoot } = await import('./presenceBoot.js'); + + const changed = resetStalePresenceOnBoot(); + + expect(changed).toBe(1); + expect(getStatus(localId)).toBe('offline'); + // Replicated rows must keep their projected remote status untouched. + expect(getStatus(remoteOnlineId)).toBe('online'); + expect(getStatus(remoteIdleId)).toBe('idle'); + expect(getStatus(remoteDndId)).toBe('dnd'); + }); + + it('does not modify soft-deleted (tombstoned) users', async () => { + const tombstonedId = insertUser({ + username: 'gone', + status: 'online', + isDeleted: 1, + }); + const { resetStalePresenceOnBoot } = await import('./presenceBoot.js'); + + const changed = resetStalePresenceOnBoot(); + + expect(changed).toBe(0); + // Tombstoned rows are excluded from presence broadcasts; their stored + // status must not be silently rewritten by a maintenance task. + expect(getStatus(tombstonedId)).toBe('online'); + }); + + it('leaves already-offline users untouched', async () => { + const offId = insertUser({ username: 'off', status: 'offline' }); + const { resetStalePresenceOnBoot } = await import('./presenceBoot.js'); + + const changed = resetStalePresenceOnBoot(); + + expect(changed).toBe(0); + expect(getStatus(offId)).toBe('offline'); + }); + + it('is idempotent — second call after the first changes nothing', async () => { + insertUser({ username: 'a', status: 'online' }); + insertUser({ username: 'b', status: 'idle' }); + const { resetStalePresenceOnBoot } = await import('./presenceBoot.js'); + + expect(resetStalePresenceOnBoot()).toBe(2); + expect(resetStalePresenceOnBoot()).toBe(0); + }); + + it('handles a mixed population correctly', async () => { + // Locally-homed: should reset 'online' and 'dnd' + const localOnline = insertUser({ username: 'lo', status: 'online' }); + const localDnd = insertUser({ username: 'ld', status: 'dnd' }); + const localOffline = insertUser({ username: 'loff', status: 'offline' }); + // Federated: should be untouched regardless of status + const remote = insertUser({ + username: 'rem', + status: 'online', + homeInstance: 'peer.example', + homeUserId: 'peer-1', + }); + // Tombstoned local: untouched + const tomb = insertUser({ + username: 'tomb', + status: 'online', + isDeleted: 1, + }); + + const { resetStalePresenceOnBoot } = await import('./presenceBoot.js'); + const changed = resetStalePresenceOnBoot(); + + expect(changed).toBe(2); + expect(getStatus(localOnline)).toBe('offline'); + expect(getStatus(localDnd)).toBe('offline'); + expect(getStatus(localOffline)).toBe('offline'); + expect(getStatus(remote)).toBe('online'); + expect(getStatus(tomb)).toBe('online'); + }); +}); diff --git a/packages/server/src/utils/presenceBoot.ts b/packages/server/src/utils/presenceBoot.ts new file mode 100644 index 00000000..46fc1af1 --- /dev/null +++ b/packages/server/src/utils/presenceBoot.ts @@ -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; +}