Skip to content

Commit 2347dfc

Browse files
committed
refactor(realtime): serialize table join/leave to fix map-corruption at the root
Round 4 on #5991 surfaced a race the generation guards structurally cannot fix: two concurrent joins for one socket race on the single-valued socket→room map — a stalled addUserToRoom for table A lands late, clobbers a newer join's map entry to A, and the rollback then wipes it, stranding the socket (map empty while it holds table B). Guards protect JS suspension points; they can't stop an in-flight Redis write from landing late. Fix per architecture review: serialize this socket's JOIN + LEAVE on a per-socket promise chain so their multi-step async Redis commits can never interleave — restoring the atomic-commit property the synchronous sibling handlers get for free. This DELETES the leave-prior guard and the post-commit rollback (the code that caused the bug); four generation guards collapse to two identical superseded() checks (skip a superseded queued op + one pre-commit check). Reworked the interleaving-specific tests into a fast-switch-skips-superseded test; the leave- cancels-join tests are unchanged. Local to the tables handler — no shared-infra change.
1 parent f09aa17 commit 2347dfc

2 files changed

Lines changed: 71 additions & 178 deletions

File tree

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

Lines changed: 12 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { ROOM_TYPES, type RoomRef } from '@sim/realtime-protocol/rooms'
4+
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
55
import { TABLE_PRESENCE_EVENTS } from '@sim/realtime-protocol/table-presence'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import type { IRoomManager } from '@/rooms'
@@ -187,30 +187,30 @@ describe('setupTablesHandlers', () => {
187187
expect(toEmit).not.toHaveBeenCalled()
188188
})
189189

190-
it('aborts a stale join whose authorize resolves after a newer join', async () => {
190+
it('skips a superseded queued join on a fast table switch', async () => {
191191
const { socket, handlers } = createSocket()
192192
const roomManager = createRoomManager()
193-
// First join's authorize hangs until released; the second resolves immediately.
194-
let releaseA: (value: unknown) => void = () => {}
195-
const pendingA = new Promise((resolve) => {
196-
releaseA = resolve
197-
})
198-
mockAuthorizeRoom.mockReturnValueOnce(pendingA).mockResolvedValue({
193+
mockAuthorizeRoom.mockResolvedValue({
199194
allowed: true,
200195
status: 200,
201196
workspaceId: 'ws-1',
202197
workspacePermission: 'admin',
203198
})
204199
setupTablesHandlers(socket as unknown as SetupArg, roomManager)
205200

206-
const joinA = handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-A' })
201+
// Two joins enqueued back-to-back (A then B). B bumps the generation synchronously, so A's
202+
// queued run no-ops at its start check — only B commits. Because JOINs are serialized on one
203+
// op chain, A's and B's Redis writes can never interleave (no map-clobber, no stranding).
204+
handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-A' })
207205
await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-B' })
208-
releaseA({ allowed: true, status: 200, workspaceId: 'ws-1', workspacePermission: 'admin' })
209-
await joinA
210206

211-
// The newer join (B) wins; the stale A join aborts before touching room state.
212207
expect(socket.join).toHaveBeenCalledWith('table:table-B')
213208
expect(socket.join).not.toHaveBeenCalledWith('table:table-A')
209+
expect(roomManager.addUserToRoom).toHaveBeenCalledWith(
210+
{ type: ROOM_TYPES.TABLE, id: 'table-B' },
211+
'socket-1',
212+
expect.anything()
213+
)
214214
expect(roomManager.addUserToRoom).not.toHaveBeenCalledWith(
215215
{ type: ROOM_TYPES.TABLE, id: 'table-A' },
216216
expect.anything(),
@@ -284,115 +284,6 @@ 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: RoomRef | null) => void = () => {}
297-
const pendingLookup = new Promise<RoomRef | null>((resolve) => {
298-
releaseLookup = resolve
299-
})
300-
let lookupCalls = 0
301-
const roomManager = createRoomManager({
302-
getRoomForSocket: vi.fn((): Promise<RoomRef | null> => {
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-
// A resumes with the socket now registered on table-B (the newer join committed there).
323-
releaseLookup({ type: ROOM_TYPES.TABLE, id: 'table-B' })
324-
await joinA
325-
326-
// A must NOT tear down B's room via its leave-prior, nor register itself on table-A.
327-
expect(socket.leave).not.toHaveBeenCalledWith('table:table-B')
328-
expect(roomManager.removeUserFromRoom).not.toHaveBeenCalledWith(
329-
{ type: ROOM_TYPES.TABLE, id: 'table-B' },
330-
'socket-1'
331-
)
332-
expect(socket.join).toHaveBeenCalledWith('table:table-B')
333-
expect(socket.join).not.toHaveBeenCalledWith('table:table-A')
334-
expect(roomManager.addUserToRoom).not.toHaveBeenCalledWith(
335-
{ type: ROOM_TYPES.TABLE, id: 'table-A' },
336-
expect.anything(),
337-
expect.anything()
338-
)
339-
})
340-
341-
it('rolls back a join superseded while addUserToRoom is in flight', async () => {
342-
const { socket, handlers } = createSocket()
343-
// A passes every guard and hangs committing to table-A; a newer join B commits to table-B
344-
// during the hang. When A resumes, the post-commit re-check must roll back A's own
345-
// registration (scoped to table-A) without touching B.
346-
let aReachedAdd: () => void = () => {}
347-
const aAtAdd = new Promise<void>((resolve) => {
348-
aReachedAdd = resolve
349-
})
350-
let releaseAdd: () => void = () => {}
351-
const pendingAdd = new Promise<void>((resolve) => {
352-
releaseAdd = resolve
353-
})
354-
let addCalls = 0
355-
const roomManager = createRoomManager({
356-
addUserToRoom: vi.fn((): Promise<void> => {
357-
addCalls += 1
358-
if (addCalls === 1) {
359-
aReachedAdd()
360-
return pendingAdd
361-
}
362-
return Promise.resolve()
363-
}),
364-
})
365-
mockAuthorizeRoom.mockResolvedValue({
366-
allowed: true,
367-
status: 200,
368-
workspaceId: 'ws-1',
369-
workspacePermission: 'admin',
370-
})
371-
setupTablesHandlers(socket as unknown as SetupArg, roomManager)
372-
373-
const joinA = handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-A' })
374-
await aAtAdd // A has passed every guard and is hung committing to table-A
375-
await handlers[TABLE_PRESENCE_EVENTS.JOIN]({ tableId: 'table-B' }) // bumps generation, commits to B
376-
releaseAdd()
377-
await joinA
378-
379-
// A rolled back its own registration (scoped to table-A) and never touched table-B.
380-
expect(socket.leave).toHaveBeenCalledWith('table:table-A')
381-
expect(roomManager.removeUserFromRoom).toHaveBeenCalledWith(
382-
{ type: ROOM_TYPES.TABLE, id: 'table-A' },
383-
'socket-1'
384-
)
385-
expect(roomManager.removeUserFromRoom).not.toHaveBeenCalledWith(
386-
{ type: ROOM_TYPES.TABLE, id: 'table-B' },
387-
'socket-1'
388-
)
389-
expect(roomManager.addUserToRoom).toHaveBeenCalledWith(
390-
{ type: ROOM_TYPES.TABLE, id: 'table-B' },
391-
'socket-1',
392-
expect.anything()
393-
)
394-
})
395-
396287
it('leaves the table room on leave', async () => {
397288
const { socket, handlers } = createSocket()
398289
const roomManager = createRoomManager({

apps/realtime/src/handlers/tables.ts

Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -62,24 +62,39 @@ function normalizeCellSelection(cell: unknown): TableCellSelection | undefined {
6262
* only because a workflow room's name equals its id).
6363
*/
6464
export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
65-
// Monotonic per-socket join counter: each JOIN captures its number and, after the async
66-
// authorize, aborts if a newer JOIN has started — a fast table switch A→B can otherwise
67-
// let A's late handler leave B and strand the socket in room A while the client views B.
65+
// Monotonic per-socket generation: each JOIN/LEAVE bumps it synchronously on arrival, and a
66+
// queued or in-flight op that finds a newer generation aborts — a fast table switch A→B thus
67+
// cancels A the instant B arrives.
6868
let joinGeneration = 0
69-
// The table the socket currently intends to be in (set when a join starts). A leave
70-
// targeting it — or an unscoped leave — advances joinGeneration to cancel an in-flight
71-
// join, so a join still awaiting authorization can't complete after the client left and
72-
// strand the socket in the room (present in presence + still receiving broadcasts). A
73-
// leave for a DIFFERENT table must NOT cancel it (a table switch), mirroring workspace-files.
69+
// The table the socket currently intends to be in (set when a join is enqueued). A leave
70+
// targeting it — or an unscoped leave — bumps the generation to cancel that join; a leave for a
71+
// DIFFERENT table must NOT (a table switch), mirroring workspace-files.
7472
let currentTableId: string | null = null
73+
// Serialize this socket's room mutations (JOIN + LEAVE) so their multi-step async Redis commits
74+
// can never interleave: two concurrent joins would otherwise race on the single-valued
75+
// socket→room map (a late addUserToRoom clobbering a newer join's entry). This restores the
76+
// atomic-commit property the synchronous sibling handlers (file-doc, workspace-files) get for
77+
// free. CELL_SELECTION is NOT chained — it only touches presence activity, never the map.
78+
let opChain: Promise<void> = Promise.resolve()
7579

76-
socket.on(TABLE_PRESENCE_EVENTS.JOIN, async ({ tableId, tabSessionId }: JoinTablePayload) => {
80+
socket.on(TABLE_PRESENCE_EVENTS.JOIN, ({ tableId, tabSessionId }: JoinTablePayload) => {
7781
const joinAttempt = (joinGeneration += 1)
7882
currentTableId = tableId
79-
// True once this JOIN has been superseded — a newer JOIN (table switch) bumped
80-
// joinGeneration, or the socket disconnected. Re-checked after each async step below so a
81-
// stale join can't mutate room state. (The catch uses a narrower check — see there.)
83+
opChain = opChain
84+
.then(() => runJoin(tableId, tabSessionId, joinAttempt))
85+
.catch((error) => logger.error('Error joining table room:', error))
86+
// Returned so callers awaiting this op (e.g. tests) can await its completion; Socket.IO
87+
// ignores a handler's return value.
88+
return opChain
89+
})
90+
91+
async function runJoin(tableId: string, tabSessionId: string | undefined, joinAttempt: number) {
92+
// True once this JOIN has been superseded — a newer JOIN/LEAVE bumped joinGeneration, or the
93+
// socket disconnected. Because ops are serialized, no other op mutates room state while this
94+
// one runs, so only two checks are needed: skip a superseded queued op (here), and one final
95+
// check right before the membership commit.
8296
const superseded = () => joinGeneration !== joinAttempt || socket.disconnected
97+
if (superseded()) return
8398
try {
8499
const userId = socket.userId
85100
const userName = socket.userName
@@ -133,20 +148,13 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
133148
})
134149
if (!authorized) return
135150

136-
// Server-authenticated avatar for the presence roster. Resolved up-front so the guard
137-
// below also covers this await (mirrors the file-doc join).
151+
// Server-authenticated avatar for the presence roster.
138152
const avatarUrl = await resolveAvatarUrl(socket, userId)
139153

140-
// Abort a JOIN superseded during authorize/avatar resolution — a newer JOIN (table
141-
// switch), a LEAVE, or a disconnect. Registering below would strand the socket.
142-
if (superseded()) return
143-
144-
// Leave a previously-joined table room if switching tables. Re-check the generation
145-
// after the lookup await: if a newer join committed to a room during it, `currentRoom`
146-
// is now that room, and leaving it here would tear down the join the client actually
147-
// holds. A superseded join must abort before this mutation.
154+
// Leave a previously-joined table room if switching tables. No generation guard is needed
155+
// around this: serialization guarantees no concurrent op committed to a different room
156+
// during the lookup, so `currentRoom` is the socket's genuine prior room, safe to leave.
148157
const currentRoom = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.TABLE)
149-
if (superseded()) return
150158
if (currentRoom && currentRoom.id !== tableId) {
151159
socket.leave(roomName(currentRoom))
152160
await roomManager.removeUserFromRoom(currentRoom, socket.id)
@@ -174,10 +182,8 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
174182
}
175183
}
176184

177-
// Final re-check immediately before the membership commit: a newer JOIN (table switch), a
178-
// LEAVE, or a disconnect during the leave/sweep awaits above must abort here — otherwise
179-
// this superseded join would join the room and register presence, stranding the socket in
180-
// the wrong table. No await sits between this guard and addUserToRoom (the commit).
185+
// Final re-check before the membership commit: a LEAVE or a newer JOIN enqueued during the
186+
// awaits above bumped the generation, or the socket disconnected. Abort before registering.
181187
if (superseded()) return
182188

183189
socket.join(roomName(room))
@@ -194,19 +200,11 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
194200
avatarUrl,
195201
}
196202

203+
// If the socket disconnects during this commit (disconnect cleanup runs off the op chain),
204+
// this write can land after it, leaving a stale presence entry. Benign and self-correcting:
205+
// filterVisiblePresence hides it and sweepStalePresence reclaims it (same as the siblings).
197206
await roomManager.addUserToRoom(room, socket.id, presence)
198207

199-
// A newer join (table switch) or a leave may have committed while addUserToRoom was in
200-
// flight — that newer join's own leave-prior can't reliably observe this half-written
201-
// entry, so undo our registration here rather than strand the socket on the wrong table.
202-
// Scoped to THIS room (`room`), so `removeUserFromRoom` only clears our socket→room map
203-
// when it still points here and never touches the newer join's room.
204-
if (superseded()) {
205-
socket.leave(roomName(room))
206-
await roomManager.removeUserFromRoom(room, socket.id)
207-
return
208-
}
209-
210208
// Filter the join ack to live members so a new joiner never briefly sees a
211209
// ghost from an entry the sweep hasn't reclaimed yet.
212210
const presenceUsers = await filterVisiblePresence(
@@ -225,9 +223,10 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
225223
logger.info(`User ${userId} (${userName}) joined table room ${tableId}`)
226224
} catch (error) {
227225
logger.error('Error joining table room:', error)
228-
// A superseded join (a newer join/leave bumped the generation) must NOT roll back — it
229-
// would tear down room state a newer successful join to the same table now holds — nor
230-
// signal an error for a table the client already left.
226+
// If a newer JOIN/LEAVE superseded this one while it ran, skip the rollback + error: the
227+
// next serialized op cleans up any partial registration via its leave-prior, and the client
228+
// already moved off this table so the error is moot. A disconnect (not a supersession) still
229+
// falls through and rolls back — hence the generation-only check, not the full `superseded`.
231230
if (joinGeneration !== joinAttempt) return
232231
// Roll back any partial join so a failed attempt can't leave the socket in the
233232
// Socket.IO room or a stale presence entry behind, before signalling a retry.
@@ -246,35 +245,38 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
246245
retryable: true,
247246
})
248247
}
248+
}
249+
250+
socket.on(TABLE_PRESENCE_EVENTS.LEAVE, (payload?: { tableId?: string }) => {
251+
// Cancel an in-flight/queued join whose table the client is now leaving (or an unscoped
252+
// leave). Scope to the current table intent so a stale/deferred leave for a DIFFERENT table
253+
// can't cancel the join the client has since switched to. Bumped synchronously here — before
254+
// the teardown is enqueued — so it cancels a running join at its next generation check.
255+
if (!payload?.tableId || payload.tableId === currentTableId) {
256+
joinGeneration += 1
257+
currentTableId = null
258+
}
259+
opChain = opChain
260+
.then(() => runLeave(payload))
261+
.catch((error) => logger.error('Error leaving table room:', error))
262+
return opChain
249263
})
250264

251-
socket.on(TABLE_PRESENCE_EVENTS.LEAVE, async (payload?: { tableId?: string }) => {
265+
async function runLeave(payload?: { tableId?: string }) {
252266
try {
253-
// Cancel an in-flight join whose table the client is now leaving (or an unscoped
254-
// leave): a join still awaiting authorization would otherwise complete after the
255-
// client left — joining the room, registering presence, and broadcasting a ghost
256-
// until disconnect. Guard on the current table intent so a stale/deferred leave for
257-
// a DIFFERENT table can't abort the join the client has since switched to. Runs
258-
// before the teardown below because the racing join has registered nothing yet
259-
// (getRoomForSocket returns null), so only this generation bump can stop it.
260-
if (!payload?.tableId || payload.tableId === currentTableId) {
261-
joinGeneration += 1
262-
currentTableId = null
263-
}
264267
if (!roomManager.isReady()) return
265268
const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.TABLE)
266269
if (!room) return
267-
// Scope the leave to a specific table when the client provides one: a deferred
268-
// leave from a prior view must not evict the socket from a room it has since
269-
// switched into (table A→B leaves A's leave targeting B).
270+
// Scope the leave to a specific table when the client provides one: a deferred leave from a
271+
// prior view must not evict the socket from a room it has since switched into.
270272
if (payload?.tableId && payload.tableId !== room.id) return
271273
socket.leave(roomName(room))
272274
await roomManager.removeUserFromRoom(room, socket.id)
273275
await roomManager.broadcastPresenceUpdate(room, socket.id)
274276
} catch (error) {
275277
logger.error('Error leaving table room:', error)
276278
}
277-
})
279+
}
278280

279281
socket.on(TABLE_PRESENCE_EVENTS.CELL_SELECTION, async ({ cell }: { cell: unknown }) => {
280282
try {

0 commit comments

Comments
 (0)