Skip to content

Commit 869679f

Browse files
committed
fix(realtime): guard table join commit + rollback against supersession; drop no-op eviction cleanup
Review round on #5991: - Table join re-checked the generation only once after authorize, then awaited leave/sweep/avatar before joining + registering presence. A table switch or leave in that window stranded the socket in the wrong room, and the failure catch could tear down a newer successful join. Resolve the avatar up-front, re-check generation immediately before the membership commit (matching the file-doc join), and skip the rollback/error for a superseded join. + a post-authorize-window regression test. - access-revalidation cleanup treated removeUserFromRoom's no-op false as a transport failure and re-enqueued a still-connected socket forever. Only retry when the socket is still mapped to the room (a healthy null mapping means the entry is already gone). Repurposed the expired-mapping test to lock it.
1 parent 3de7af1 commit 869679f

4 files changed

Lines changed: 86 additions & 16 deletions

File tree

apps/realtime/src/access-revalidation.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -226,25 +226,25 @@ describe('access-revalidation sweep', () => {
226226
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' })
227227
})
228228

229-
it('defers cleanup when removal fails with expired socket mappings', async () => {
229+
it('drops eviction cleanup when the socket is no longer mapped to the room (no infinite retry)', async () => {
230230
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
231231
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
232-
// Mapping keys already expired (lookup resolves null) AND the removal fails
233-
// (the Redis manager swallows the transport error into null) — the failed
234-
// removal must still defer instead of reading as success.
235-
manager.removeUserFromRoom.mockResolvedValueOnce(false)
232+
// A healthy lookup shows the socket is no longer mapped to any workflow room (its presence
233+
// is already gone), and removeUserFromRoom reports a no-op `false`. This is "already clean",
234+
// not a deferrable failure — the cleanup must drop it, never re-enqueue a still-connected
235+
// socket forever. (A genuine failure — still mapped + false — is covered by the next test.)
236+
manager.getRoomForSocket.mockResolvedValue(null)
237+
manager.removeUserFromRoom.mockResolvedValue(false)
236238
mockResolveRole.mockResolvedValue(null)
237239

238240
const sweep = startAccessRevalidationSweep(manager)
239241
await sweep.runOnce()
240-
241-
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
242-
243242
await sweep.runOnce()
244243
sweep.stop()
245244

246-
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2)
247-
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith({ type: 'workflow', id: 'wf-1' })
245+
// Attempted once, then dropped — not re-enqueued across passes, and no broadcast.
246+
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(1)
247+
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
248248
})
249249

250250
it('defers cleanup when the manager swallows a removal failure into null', async () => {

apps/realtime/src/access-revalidation.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,18 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
172172
// entry from the known target room via the explicit ref below.
173173
const removed = await roomManager.removeUserFromRoom(wf(workflowId), socketId)
174174
if (!removed) {
175-
// The socket is still connected and hasn't moved/re-joined, yet the removal
176-
// wasn't confirmed — a transport error the manager swallowed into false, or a
177-
// lost race. Defer and retry next sweep rather than leave a revoked
178-
// collaborator's stale presence entry behind.
179-
throw new Error('room-state removal not confirmed')
175+
// `false` conflates two outcomes: the entry was already gone (a no-op), or a
176+
// transport error the manager swallowed. Only retry when the socket is still mapped
177+
// to THIS room — then a false result is a genuine, deferrable failure. When a healthy
178+
// getRoomForSocket above returned no workflow mapping (`currentWorkflowId === null`),
179+
// the presence entry is already gone, so the cleanup is complete: dropping it avoids
180+
// re-enqueuing a still-connected socket forever. (A real Redis outage throws at
181+
// getRoomForSocket and is deferred by the outer catch, never reaching here.)
182+
if (currentWorkflowId === workflowId) {
183+
throw new Error('room-state removal not confirmed')
184+
}
185+
pendingCleanups.delete(key)
186+
return
180187
}
181188

182189
await roomManager.broadcastPresenceUpdate(wf(workflowId))

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,53 @@ describe('setupTablesHandlers', () => {
284284
)
285285
})
286286

287+
it('aborts a join superseded during the post-authorize leave/sweep window', async () => {
288+
const { socket, handlers } = createSocket()
289+
// Hold A hung on its leave-prior lookup (which runs AFTER the post-authorize recheck), then
290+
// fire a newer join B. When A resumes, the final generation guard before the membership
291+
// commit must abort it — the post-authorize awaits are no longer an unguarded window.
292+
let aReachedLookup: () => void = () => {}
293+
const aAtLookup = new Promise<void>((resolve) => {
294+
aReachedLookup = resolve
295+
})
296+
let releaseLookup: (value: unknown) => void = () => {}
297+
const pendingLookup = new Promise((resolve) => {
298+
releaseLookup = resolve
299+
})
300+
let lookupCalls = 0
301+
const roomManager = createRoomManager({
302+
getRoomForSocket: vi.fn(() => {
303+
lookupCalls += 1
304+
if (lookupCalls === 1) {
305+
aReachedLookup()
306+
return pendingLookup
307+
}
308+
return Promise.resolve(null)
309+
}),
310+
})
311+
mockAuthorizeRoom.mockResolvedValue({
312+
allowed: true,
313+
status: 200,
314+
workspaceId: 'ws-1',
315+
workspacePermission: 'admin',
316+
})
317+
setupTablesHandlers(socket as unknown as SetupArg, roomManager)
318+
319+
const joinA = handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-A' })
320+
await aAtLookup // A has passed its post-authorize recheck and is hung on the leave-prior lookup
321+
await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-B' }) // bumps generation, completes
322+
releaseLookup(null)
323+
await joinA
324+
325+
expect(socket.join).toHaveBeenCalledWith('table:table-B')
326+
expect(socket.join).not.toHaveBeenCalledWith('table:table-A')
327+
expect(roomManager.addUserToRoom).not.toHaveBeenCalledWith(
328+
{ type: ROOM_TYPES.TABLE, id: 'table-A' },
329+
expect.anything(),
330+
expect.anything()
331+
)
332+
})
333+
287334
it('leaves the table room on leave', async () => {
288335
const { socket, handlers } = createSocket()
289336
const roomManager = createRoomManager({

apps/realtime/src/handlers/tables.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
131131

132132
// A newer JOIN started on this socket during authorize (or the socket dropped):
133133
// abort so a stale join can't leave the room the client has since moved to.
134+
// Server-authenticated avatar for the presence roster. Resolved up-front so the guard
135+
// below also covers this await (mirrors the file-doc join).
136+
const avatarUrl = await resolveAvatarUrl(socket, userId)
137+
138+
// Abort a JOIN superseded during authorize/avatar resolution — a newer JOIN (table
139+
// switch), a LEAVE, or a disconnect. Registering below would strand the socket.
134140
if (joinGeneration !== joinAttempt || socket.disconnected) return
135141

136142
// Leave a previously-joined table room if switching tables.
@@ -162,6 +168,12 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
162168
}
163169
}
164170

171+
// Final re-check immediately before the membership commit: a newer JOIN (table switch), a
172+
// LEAVE, or a disconnect during the leave/sweep awaits above must abort here — otherwise
173+
// this superseded join would join the room and register presence, stranding the socket in
174+
// the wrong table. No await sits between this guard and addUserToRoom (the commit).
175+
if (joinGeneration !== joinAttempt || socket.disconnected) return
176+
165177
socket.join(roomName(room))
166178

167179
const presence: UserPresence = {
@@ -173,7 +185,7 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
173185
joinedAt: Date.now(),
174186
lastActivity: Date.now(),
175187
role: authorized.workspacePermission ?? 'read',
176-
avatarUrl: await resolveAvatarUrl(socket, userId),
188+
avatarUrl,
177189
}
178190

179191
await roomManager.addUserToRoom(room, socket.id, presence)
@@ -196,6 +208,10 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
196208
logger.info(`User ${userId} (${userName}) joined table room ${tableId}`)
197209
} catch (error) {
198210
logger.error('Error joining table room:', error)
211+
// A superseded join (a newer join/leave bumped the generation) must NOT roll back — it
212+
// would tear down room state a newer successful join to the same table now holds — nor
213+
// signal an error for a table the client already left.
214+
if (joinGeneration !== joinAttempt) return
199215
// Roll back any partial join so a failed attempt can't leave the socket in the
200216
// Socket.IO room or a stale presence entry behind, before signalling a retry.
201217
try {

0 commit comments

Comments
 (0)