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
@@ -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');
});
});