Skip to content

Commit 1086dc3

Browse files
committed
fix(realtime): keep handler-initiated table eviction retryable
Evicting leaves the Socket.IO room synchronously, which is also how the sweep discovers work — so a presence removal failing in the per-frame path could never be retried and left a ghost collaborator until disconnect. Failed (or unconfirmed) removals now hand off to the sweep's existing cleanup lane instead of a second retry loop.
1 parent 98d82bd commit 1086dc3

4 files changed

Lines changed: 115 additions & 8 deletions

File tree

apps/realtime/src/access-revalidation.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
roomName,
1010
} from '@sim/realtime-protocol/rooms'
1111
import { sleep } from '@sim/utils/helpers'
12-
import { evictSocketFromRoom, runRoomEvictionHandler } from '@/handlers/room-eviction'
12+
import {
13+
evictSocketFromRoom,
14+
runRoomEvictionHandler,
15+
setEvictionCleanupSink,
16+
} from '@/handlers/room-eviction'
1317
import type { AuthenticatedSocket } from '@/middleware/auth'
1418
import { ROLE_REVALIDATION_TTL_MS, resolveCurrentRoomPermission } from '@/middleware/permissions'
1519
import type { IRoomManager } from '@/rooms'
@@ -255,6 +259,14 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
255259
})
256260
}
257261

262+
// Handler-initiated evictions (the per-frame gates) hand failed presence cleanups
263+
// here: they have already left the Socket.IO room, so the scan can no longer
264+
// rediscover them, and this lane is the only thing that retries.
265+
setEvictionCleanupSink((socketId, room) => {
266+
pendingCleanups.set(`${socketId}:${roomName(room)}`, { socketId, room })
267+
launchCleanups()
268+
})
269+
258270
function revokeSocket(socket: AuthenticatedSocket, room: RoomRef, name: string): void {
259271
// Security-critical, pod-local, and synchronous: stop this socket receiving
260272
// room broadcasts immediately, and drop the handler-local state that would
@@ -380,7 +392,10 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
380392
)
381393

382394
return {
383-
stop: () => clearInterval(timer),
395+
stop: () => {
396+
clearInterval(timer)
397+
setEvictionCleanupSink(null)
398+
},
384399
runOnce,
385400
}
386401
}

apps/realtime/src/handlers/room-eviction.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,37 @@ export function runRoomEvictionHandler(socketId: string, room: RoomRef, io: Serv
4646
}
4747
}
4848

49+
/** Enqueues a presence cleanup owed for an evicted socket. */
50+
type EvictionCleanupSink = (socketId: string, room: RoomRef) => void
51+
52+
let evictionCleanupSink: EvictionCleanupSink | null = null
53+
54+
/**
55+
* Registers the retrying presence-cleanup lane (owned by the access-revalidation
56+
* sweep) that handler-initiated evictions hand failed cleanups to.
57+
*
58+
* An eviction removes the socket from the Socket.IO room synchronously, which is
59+
* also how the sweep discovers work — so once a handler evicts, the sweep can no
60+
* longer find that (socket, room) pair to retry a Redis removal that failed. This
61+
* sink is the handoff that keeps the sweep's retry semantics (re-join guard,
62+
* moved-room guard, no infinite retry) as the single implementation instead of a
63+
* second, subtly different retry loop per handler. Unset outside a running sweep,
64+
* where {@link requestEvictionCleanup} is a no-op.
65+
*/
66+
export function setEvictionCleanupSink(sink: EvictionCleanupSink | null): void {
67+
evictionCleanupSink = sink
68+
}
69+
70+
/**
71+
* Asks the sweep's cleanup lane to (re)try presence removal for a socket already
72+
* evicted from `room`. Call only when the immediate best-effort removal failed —
73+
* the happy path stays immediate so peers see the departure without waiting for a
74+
* sweep tick.
75+
*/
76+
export function requestEvictionCleanup(socketId: string, room: RoomRef): void {
77+
evictionCleanupSink?.(socketId, room)
78+
}
79+
4980
/**
5081
* Evicts one socket from one non-workflow room after a confirmed loss of access:
5182
* tell the client, stop it receiving room broadcasts, and drop the handler-local

apps/realtime/src/handlers/tables.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ vi.mock('@sim/platform-authz/rooms', () => ({
1919
authorizeRoom: mockAuthorizeRoom,
2020
}))
2121

22+
import { setEvictionCleanupSink } from '@/handlers/room-eviction'
2223
import { setupTablesHandlers } from '@/handlers/tables'
2324
import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions'
2425

@@ -251,6 +252,50 @@ describe('setupTablesHandlers', () => {
251252
}
252253
})
253254

255+
it('hands a failed presence removal to the sweep so the eviction stays retryable', async () => {
256+
// The eviction already left the Socket.IO room, so the sweep's scan can no longer
257+
// rediscover this socket — a failed removal here would strand a ghost collaborator
258+
// until disconnect unless it is handed to the retrying cleanup lane.
259+
vi.useFakeTimers()
260+
const owed: Array<{ socketId: string; roomId: string }> = []
261+
setEvictionCleanupSink((socketId, room) => owed.push({ socketId, roomId: room.id }))
262+
try {
263+
const room = { type: ROOM_TYPES.TABLE, id: 'table-defer' }
264+
const { socket, handlers } = createSocket({ id: 'socket-defer', userId: 'user-defer' })
265+
const roomManager = createRoomManager({
266+
getRoomForSocket: vi.fn().mockResolvedValue(room),
267+
removeUserFromRoom: vi.fn().mockRejectedValue(new Error('redis down')),
268+
})
269+
setupTablesHandlers(socket as unknown as SetupArg, roomManager)
270+
271+
await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-defer' })
272+
await vi.advanceTimersByTimeAsync(0)
273+
274+
mockAuthorizeRoom.mockResolvedValue({
275+
allowed: false,
276+
status: 403,
277+
workspaceId: 'ws-1',
278+
workspacePermission: null,
279+
})
280+
await vi.advanceTimersByTimeAsync(31_000)
281+
282+
const cell = {
283+
anchor: { rowId: 'row-1', columnId: 'col-a' },
284+
focus: { rowId: 'row-1', columnId: 'col-a' },
285+
}
286+
await handlers[TABLE_PRESENCE_EVENTS.CELL_SELECTION]({ cell })
287+
await vi.advanceTimersByTimeAsync(0)
288+
await handlers[TABLE_PRESENCE_EVENTS.CELL_SELECTION]({ cell })
289+
await vi.advanceTimersByTimeAsync(0)
290+
291+
expect(socket.leave).toHaveBeenCalledWith('table:table-defer')
292+
expect(owed).toEqual([{ socketId: 'socket-defer', roomId: 'table-defer' }])
293+
} finally {
294+
setEvictionCleanupSink(null)
295+
vi.useRealTimers()
296+
}
297+
})
298+
254299
it('drops a malformed cell selection without storing or relaying it', async () => {
255300
const { socket, handlers, toEmit } = createSocket()
256301
const roomManager = createRoomManager({

apps/realtime/src/handlers/tables.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
type TableCellSelection,
99
} from '@sim/realtime-protocol/table-presence'
1010
import { resolveAvatarUrl } from '@/handlers/avatar'
11-
import { evictSocketFromRoom } from '@/handlers/room-eviction'
11+
import { evictSocketFromRoom, requestEvictionCleanup } from '@/handlers/room-eviction'
1212
import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
1313
import type { AuthenticatedSocket } from '@/middleware/auth'
1414
import { peekRoomPermission, resolveCurrentRoomPermission } from '@/middleware/permissions'
@@ -90,9 +90,14 @@ function normalizeCellSelection(cell: unknown): TableCellSelection | undefined {
9090

9191
/**
9292
* Evicts a socket from a table room after a confirmed loss of access, then drops
93-
* its presence so peers stop seeing its selection. The presence half is
94-
* best-effort — the socket has already left the room, and the sweep's cleanup lane
95-
* retries any removal that fails here.
93+
* its presence so peers stop seeing its selection.
94+
*
95+
* The eviction leaves the Socket.IO room immediately, which is also how the sweep
96+
* discovers work — so a presence removal that fails here would be unretryable and
97+
* strand a ghost collaborator until disconnect. The removal is therefore attempted
98+
* inline (peers see the departure at once) and handed to the sweep's retrying
99+
* cleanup lane if it fails or reports nothing removed, which is the same signal
100+
* the sweep treats as a deferrable failure.
96101
*/
97102
async function evictFromTable(
98103
socket: AuthenticatedSocket,
@@ -101,10 +106,21 @@ async function evictFromTable(
101106
): Promise<void> {
102107
evictSocketFromRoom(socket, room, 'Your access to this table has been revoked', roomManager.io)
103108
try {
104-
await roomManager.removeUserFromRoom(room, socket.id)
109+
// `false` conflates "already gone" with a transport error the manager swallowed;
110+
// deferring on it is harmless (the lane drops a cleanup it finds already clean)
111+
// and is the only signal a swallowed failure gives us.
112+
const removed = await roomManager.removeUserFromRoom(room, socket.id)
113+
if (!removed) {
114+
requestEvictionCleanup(socket.id, room)
115+
return
116+
}
105117
await roomManager.broadcastPresenceUpdate(room, socket.id)
106118
} catch (error) {
107-
logger.warn(`Presence cleanup failed for evicted table socket ${socket.id}`, error)
119+
logger.warn(
120+
`Presence cleanup failed for evicted table socket ${socket.id}; deferring to the sweep`,
121+
error
122+
)
123+
requestEvictionCleanup(socket.id, room)
108124
}
109125
}
110126

0 commit comments

Comments
 (0)