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
+32 -7
View File
@@ -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
+1 -2
View File
@@ -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
+9
View File
@@ -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<void> {
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);
+8 -3
View File
@@ -308,13 +308,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}
}
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);
@@ -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<typeof drizzle<typeof schema>>;
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<typeof schema.users.$inferInsert>): 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');
});
});
+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;
}