Skip to content

Commit 42d1d32

Browse files
committed
fix(realtime): narrow join commit-guard to post-success; skip empty presence broadcast
Two Cursor findings on the prior audit-fix commit: - The 'committed' guard in join-workflow/join-table was too broad: a failure BETWEEN the membership commit and the success ack (e.g. getWorkflowState) hit 'if (committed) return' and emitted neither success nor error, hanging the client while it sat in the room. Replaced with the narrower shape: only the purely-decorative post-success steps (peer broadcast + log-metric) are wrapped best-effort; anything before the success ack still rolls back and surfaces a retryable error, so the client retries instead of hanging — while the original goal (a benign broadcast/metric blip never kicking a live, acked user) holds. - broadcastPresenceUpdate read the roster via getRoomUsers, which swallows a Redis transport error to []. On a disconnect broadcast that emitted an empty roster and cleared every remaining collaborator's presence until the next healthy update. Split out a throwing readRoomUsers; broadcastPresenceUpdate now skips the broadcast on a read failure (getRoomUsers keeps its swallow contract). - Tests: pre-success failure rolls back + retryable error (no hang); post-success failure keeps the user joined. Gates: realtime tsc 0, 206 realtime tests, biome, boundaries, prune.
1 parent dfb6353 commit 42d1d32

4 files changed

Lines changed: 87 additions & 39 deletions

File tree

apps/realtime/src/handlers/tables.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,6 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
9595
// check right before the membership commit.
9696
const superseded = () => joinGeneration !== joinAttempt || socket.disconnected
9797
if (superseded()) return
98-
// Flips true once addUserToRoom lands: past that point the user is genuinely joined, so a
99-
// failure in the trailing ack/broadcast steps must NOT roll them back (see the catch).
100-
let committed = false
10198
try {
10299
const userId = socket.userId
103100
const userName = socket.userName
@@ -207,7 +204,6 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
207204
// this write can land after it, leaving a stale presence entry. Benign and self-correcting:
208205
// filterVisiblePresence hides it and sweepStalePresence reclaims it (same as the siblings).
209206
await roomManager.addUserToRoom(room, socket.id, presence)
210-
committed = true
211207

212208
// Filter the join ack to live members so a new joiner never briefly sees a
213209
// ghost from an entry the sweep hasn't reclaimed yet.
@@ -222,19 +218,24 @@ export function setupTablesHandlers(socket: AuthenticatedSocket, roomManager: IR
222218
presenceUsers,
223219
})
224220

225-
await roomManager.broadcastPresenceUpdate(room)
221+
// Post-success, purely decorative: notify peers. The user is already joined and acked, so a
222+
// Redis blip here must not surface as a join failure — swallow it (the next healthy broadcast
223+
// reconciles peers). Kept OUT of the rollback catch below, which is only for pre-success failures.
224+
try {
225+
await roomManager.broadcastPresenceUpdate(room)
226+
} catch (error) {
227+
logger.warn(`Post-join presence broadcast failed for table room ${tableId}`, error)
228+
}
226229

227230
logger.info(`User ${userId} (${userName}) joined table room ${tableId}`)
228231
} catch (error) {
229232
logger.error('Error joining table room:', error)
230-
// Past the membership commit the user is genuinely joined; a failure in the trailing
231-
// ack/broadcast steps must not tear them out of the room. Leave them joined.
232-
if (committed) return
233233
// Roll back a partial join: cleanup keys off the socket→room map, so a `socket.join` that
234234
// landed without a matching `addUserToRoom` (a throw in between) would otherwise leave the
235-
// socket stranded in the Socket.IO room, unreclaimable by any later op. Safe to run even when
236-
// superseded — serialization means the newer op hasn't committed yet, so this touches only
237-
// this join's own (this-table) state, never the newer op's room.
235+
// socket stranded in the Socket.IO room, unreclaimable by any later op. A failure between the
236+
// commit and the success ack rolls back too and surfaces a retryable error, so the client
237+
// retries rather than hanging. Safe to run even when superseded — serialization means the
238+
// newer op hasn't committed yet, so this touches only this join's own (this-table) state.
238239
try {
239240
const room = tableRoom(tableId)
240241
socket.leave(roomName(room))

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,31 @@ describe('setupWorkflowHandlers', () => {
374374
expect(socket.emit).not.toHaveBeenCalledWith('join-workflow-error', expect.anything())
375375
})
376376

377+
it('rolls back and surfaces a retryable error when a pre-success step fails after commit', async () => {
378+
// getWorkflowState runs after addUserToRoom but before the success ack — its failure must roll
379+
// back and emit a retryable error so the client retries, never hanging committed-but-unacked.
380+
mockGetWorkflowState.mockRejectedValue(new Error('db blip'))
381+
const { socket, handlers } = createSocket()
382+
const roomManager = createRoomManager()
383+
384+
setupWorkflowHandlers(
385+
socket as unknown as Parameters<typeof setupWorkflowHandlers>[0],
386+
roomManager
387+
)
388+
389+
await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' })
390+
391+
expect(socket.emit).not.toHaveBeenCalledWith('join-workflow-success', expect.anything())
392+
expect(roomManager.removeUserFromRoom).toHaveBeenCalledWith(
393+
{ type: 'workflow', id: 'workflow-1' },
394+
'socket-1'
395+
)
396+
expect(socket.emit).toHaveBeenCalledWith(
397+
'join-workflow-error',
398+
expect.objectContaining({ code: 'JOIN_WORKFLOW_FAILED', retryable: true })
399+
)
400+
})
401+
377402
it('leaves the workflow room even when the session key has expired', async () => {
378403
const { socket, handlers } = createSocket()
379404
const roomManager = createRoomManager({

apps/realtime/src/handlers/workflow.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,6 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
4242
// check right before the membership commit.
4343
const superseded = () => joinGeneration !== joinAttempt || socket.disconnected
4444
if (superseded()) return
45-
// Flips true once addUserToRoom lands: past that point the user is genuinely joined, so a
46-
// failure in the trailing ack/broadcast/metric steps must NOT roll them back (see the catch).
47-
let committed = false
4845
try {
4946
const userId = socket.userId
5047
const userName = socket.userName
@@ -223,7 +220,6 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
223220

224221
// Add user to room — the membership commit.
225222
await roomManager.addUserToRoom(wf(workflowId), socket.id, userPresence)
226-
committed = true
227223

228224
// Get current presence list for the join acknowledgment, filtered to live members so a new
229225
// joiner never sees a ghost from an entry the stale sweep hasn't reclaimed yet.
@@ -246,22 +242,26 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager:
246242
// Send workflow state
247243
socket.emit('workflow-state', workflowState)
248244

249-
// Broadcast presence update to all users in the room
250-
await roomManager.broadcastPresenceUpdate(wf(workflowId))
251-
252-
const uniqueUserCount = await roomManager.getUniqueUserCount(wf(workflowId))
253-
logger.info(
254-
`User ${userId} (${userName}) joined workflow ${workflowId}. Room now has ${uniqueUserCount} unique users.`
255-
)
245+
// Post-success, purely decorative: notify peers and log the count. The user is already joined
246+
// and acked, so a Redis blip here must not surface as a join failure — swallow it (the next
247+
// healthy presence broadcast reconciles peers). It must stay OUT of the rollback catch below,
248+
// which is only for pre-success failures.
249+
try {
250+
await roomManager.broadcastPresenceUpdate(wf(workflowId))
251+
const uniqueUserCount = await roomManager.getUniqueUserCount(wf(workflowId))
252+
logger.info(
253+
`User ${userId} (${userName}) joined workflow ${workflowId}. Room now has ${uniqueUserCount} unique users.`
254+
)
255+
} catch (error) {
256+
logger.warn(`Post-join presence broadcast failed for workflow ${workflowId}`, error)
257+
}
256258
} catch (error) {
257259
logger.error('Error joining workflow:', error)
258-
// Past the membership commit the user is genuinely joined; a failure in the trailing
259-
// ack/broadcast/metric steps must not tear them out of the room (a benign Redis blip on a
260-
// pure log-metric call would otherwise kick a live collaborator). Leave them joined.
261-
if (committed) return
262260
// Roll back a partial join: cleanup keys off the socket→room map, so a `socket.join` that
263261
// landed without a matching `addUserToRoom` (a throw in between) would otherwise strand the
264-
// socket in the Socket.IO room, unreclaimable by any later op. Safe even when superseded —
262+
// socket in the Socket.IO room, unreclaimable by any later op. A failure between the commit
263+
// and the success ack rolls back too and surfaces a retryable error — so the client retries
264+
// rather than hanging (never left committed-but-unacked). Safe even when superseded —
265265
// serialization means the newer op hasn't committed yet, so this touches only this join's own
266266
// room state, never the newer op's.
267267
socket.leave(workflowId)

apps/realtime/src/rooms/redis-manager.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -298,19 +298,28 @@ export class RedisRoomManager implements IRoomManager {
298298
}
299299
}
300300

301+
/**
302+
* Reads and parses the room roster. Throws on a transport error (so a caller can
303+
* distinguish "genuinely empty" from "read failed"); a single corrupted entry is
304+
* skipped, not fatal.
305+
*/
306+
private async readRoomUsers(room: RoomRef): Promise<UserPresence[]> {
307+
const users = await this.redis.hGetAll(KEYS.roomUsers(room))
308+
return Object.entries(users)
309+
.map(([socketId, json]) => {
310+
try {
311+
return JSON.parse(json) as UserPresence
312+
} catch {
313+
logger.warn(`Corrupted user data for socket ${socketId}, skipping`)
314+
return null
315+
}
316+
})
317+
.filter((u): u is UserPresence => u !== null)
318+
}
319+
301320
async getRoomUsers(room: RoomRef): Promise<UserPresence[]> {
302321
try {
303-
const users = await this.redis.hGetAll(KEYS.roomUsers(room))
304-
return Object.entries(users)
305-
.map(([socketId, json]) => {
306-
try {
307-
return JSON.parse(json) as UserPresence
308-
} catch {
309-
logger.warn(`Corrupted user data for socket ${socketId}, skipping`)
310-
return null
311-
}
312-
})
313-
.filter((u): u is UserPresence => u !== null)
322+
return await this.readRoomUsers(room)
314323
} catch (error) {
315324
logger.error(`Failed to get room users for ${room.type}:${room.id}:`, error)
316325
return []
@@ -374,7 +383,20 @@ export class RedisRoomManager implements IRoomManager {
374383
}
375384

376385
async broadcastPresenceUpdate(room: RoomRef, excludeSocketId?: string): Promise<void> {
377-
const users = await this.getRoomUsers(room)
386+
let users: UserPresence[]
387+
try {
388+
// Read via the throwing variant, NOT getRoomUsers: a transport error there returns `[]`, which
389+
// would broadcast an empty roster and clear every remaining collaborator's presence until the
390+
// next healthy update. Skip instead — the next successful join/activity broadcast (or the
391+
// stale sweep) reconciles peers.
392+
users = await this.readRoomUsers(room)
393+
} catch (error) {
394+
logger.error(
395+
`Skipping presence broadcast for ${room.type}:${room.id} (roster read failed):`,
396+
error
397+
)
398+
return
399+
}
378400
const visible = await filterVisiblePresence(this._io, room, users, excludeSocketId)
379401
// io.to() with the Redis adapter broadcasts to all pods.
380402
this._io.to(roomName(room)).emit(presenceEventName(room.type), visible)

0 commit comments

Comments
 (0)