Skip to content

Commit ca3b4d2

Browse files
icecrasher321claude
andcommitted
fix(realtime): scope room removal to the target workflow and detect swallowed cleanup failures
Honor the workflowIdHint as the target room in both room managers so removing a stale room cannot clobber the mapping of a room the socket has since moved to, and confirm eviction cleanup via the returned workflowId plus an unswallowed mapping read so Redis failures actually defer into the retry queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 840080d commit ca3b4d2

5 files changed

Lines changed: 99 additions & 17 deletions

File tree

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,15 @@ function makeManager(sockets: FakeSocket[], presence: Partial<UserPresence>[] =
3939
io: { sockets: { sockets: socketMap } },
4040
isReady: () => true,
4141
getWorkflowUsers: vi.fn().mockResolvedValue(presence),
42-
removeUserFromRoom: vi.fn().mockResolvedValue(null),
42+
getWorkflowIdForSocket: vi.fn().mockResolvedValue(null),
43+
removeUserFromRoom: vi
44+
.fn()
45+
.mockImplementation(async (_socketId: string, workflowId?: string) => workflowId ?? null),
4346
broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined),
4447
}
4548
return manager as unknown as IRoomManager & {
4649
getWorkflowUsers: ReturnType<typeof vi.fn>
50+
getWorkflowIdForSocket: ReturnType<typeof vi.fn>
4751
removeUserFromRoom: ReturnType<typeof vi.fn>
4852
broadcastPresenceUpdate: ReturnType<typeof vi.fn>
4953
}
@@ -185,6 +189,47 @@ describe('access-revalidation sweep', () => {
185189
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
186190
})
187191

192+
it('defers cleanup when the manager swallows a removal failure into null', async () => {
193+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
194+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
195+
// Live mapping but the removal reports nothing removed — the Redis manager
196+
// swallows transport errors into null, so this is the only failure signal.
197+
manager.getWorkflowIdForSocket.mockResolvedValue('wf-1')
198+
manager.removeUserFromRoom.mockResolvedValueOnce(null)
199+
mockResolveRole.mockResolvedValue(null)
200+
201+
const sweep = startAccessRevalidationSweep(manager)
202+
await sweep.runOnce()
203+
204+
expect(socket.leave).toHaveBeenCalledWith('wf-1')
205+
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
206+
207+
// Next pass: the socket left the room, and the removal now succeeds.
208+
socket.rooms = new Set(['sock-1'])
209+
await sweep.runOnce()
210+
sweep.stop()
211+
212+
expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2)
213+
expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1')
214+
})
215+
216+
it('skips removal when the socket has since moved to a different workflow', async () => {
217+
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
218+
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])
219+
// Between the membership snapshot and cleanup, the socket switched to a
220+
// workflow it can still access — removal must not touch its new presence.
221+
manager.getWorkflowIdForSocket.mockResolvedValue('wf-2')
222+
mockResolveRole.mockResolvedValue(null)
223+
224+
const sweep = startAccessRevalidationSweep(manager)
225+
await sweep.runOnce()
226+
sweep.stop()
227+
228+
expect(socket.leave).toHaveBeenCalledWith('wf-1')
229+
expect(manager.removeUserFromRoom).not.toHaveBeenCalled()
230+
expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled()
231+
})
232+
188233
it('drops a deferred cleanup when the socket legitimately re-joined the room', async () => {
189234
const socket = makeSocket('sock-1', 'user-1', 'wf-1')
190235
const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }])

apps/realtime/src/access-revalidation.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,24 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR
8484
async function cleanupEvictedSocket(socketId: string, workflowId: string): Promise<void> {
8585
const key = `${socketId}:${workflowId}`
8686
try {
87-
await roomManager.removeUserFromRoom(socketId, workflowId)
87+
// Unlike removeUserFromRoom, this read does not swallow transport errors,
88+
// so a Redis outage lands in the catch below and defers the cleanup.
89+
const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socketId)
90+
if (currentWorkflowId !== null && currentWorkflowId !== workflowId) {
91+
// The socket has since moved to a different workflow it can still
92+
// access; that join's room switch already removed this room's presence
93+
// entry, so there is nothing stale left to clean here.
94+
pendingCleanups.delete(key)
95+
return
96+
}
97+
98+
const removed = await roomManager.removeUserFromRoom(socketId, workflowId)
99+
if (removed === null && currentWorkflowId !== null) {
100+
// The Redis manager swallows transport errors into null — a live
101+
// mapping with no reported removal means the removal did not happen.
102+
throw new Error('room-state removal not confirmed')
103+
}
104+
88105
await roomManager.broadcastPresenceUpdate(workflowId)
89106
pendingCleanups.delete(key)
90107
} catch (error) {

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,19 @@ export class MemoryRoomManager implements IRoomManager {
6666
logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`)
6767
}
6868

69-
async removeUserFromRoom(socketId: string, _workflowIdHint?: string): Promise<string | null> {
70-
const workflowId = this.socketToWorkflow.get(socketId)
69+
async removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise<string | null> {
70+
const currentWorkflowId = this.socketToWorkflow.get(socketId) ?? null
71+
const workflowId = workflowIdHint ?? currentWorkflowId
7172

7273
if (!workflowId) {
7374
return null
7475
}
7576

7677
const room = this.workflowRooms.get(workflowId)
7778
if (room) {
78-
room.users.delete(socketId)
79-
room.activeConnections = Math.max(0, room.activeConnections - 1)
79+
if (room.users.delete(socketId)) {
80+
room.activeConnections = Math.max(0, room.activeConnections - 1)
81+
}
8082

8183
// Clean up empty rooms
8284
if (room.activeConnections === 0) {
@@ -85,8 +87,13 @@ export class MemoryRoomManager implements IRoomManager {
8587
}
8688
}
8789

88-
this.socketToWorkflow.delete(socketId)
89-
this.userSessions.delete(socketId)
90+
// Only clear the socket's own mappings when it is not mapped to a different
91+
// room — removing a stale room's entry must not destroy the mapping of a
92+
// room the socket has since moved to.
93+
if (currentWorkflowId === null || currentWorkflowId === workflowId) {
94+
this.socketToWorkflow.delete(socketId)
95+
this.userSessions.delete(socketId)
96+
}
9097

9198
logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`)
9299
return workflowId

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60
1818

1919
/**
2020
* Lua script for atomic user removal from room.
21-
* Returns workflowId if user was removed, null otherwise.
21+
* The hint, when provided, is the target room to remove membership from; the
22+
* socket's current mapping is only the fallback. Socket-level keys are cleared
23+
* only when the socket is not mapped to a different room, so removing a stale
24+
* room cannot destroy the mapping of a room the socket has since moved to.
25+
* Returns the target workflowId, or null when no target could be resolved.
2226
* Handles room cleanup atomically to prevent race conditions.
2327
*/
2428
const REMOVE_USER_SCRIPT = `
@@ -30,12 +34,13 @@ local workflowMetaPrefix = ARGV[2]
3034
local socketId = ARGV[3]
3135
local workflowIdHint = ARGV[4]
3236
33-
local workflowId = redis.call('GET', socketWorkflowKey)
34-
if not workflowId then
35-
workflowId = redis.call('GET', socketPresenceWorkflowKey)
37+
local currentWorkflowId = redis.call('GET', socketWorkflowKey)
38+
if not currentWorkflowId then
39+
currentWorkflowId = redis.call('GET', socketPresenceWorkflowKey)
3640
end
3741
38-
if not workflowId and workflowIdHint ~= '' then
42+
local workflowId = currentWorkflowId
43+
if workflowIdHint ~= '' then
3944
workflowId = workflowIdHint
4045
end
4146
@@ -47,7 +52,10 @@ local workflowUsersKey = workflowUsersPrefix .. workflowId .. ':users'
4752
local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta'
4853
4954
redis.call('HDEL', workflowUsersKey, socketId)
50-
redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey)
55+
56+
if (not currentWorkflowId) or currentWorkflowId == workflowId then
57+
redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey)
58+
end
5159
5260
local remaining = redis.call('HLEN', workflowUsersKey)
5361
if remaining == 0 then

apps/realtime/src/rooms/types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,14 @@ export interface IRoomManager {
6464
addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise<void>
6565

6666
/**
67-
* Remove a user from their current room
68-
* Optional workflowIdHint is used when socket mapping keys are missing/expired.
69-
* Returns the workflowId they were in, or null if not in any room.
67+
* Remove a user's membership of a workflow room.
68+
* When workflowIdHint is provided it is the target room; the socket's current
69+
* mapping is only the fallback (and covers missing/expired mapping keys).
70+
* Socket-level mappings are cleared only when the socket is not mapped to a
71+
* different room, so removing a stale room cannot destroy the mapping of a
72+
* room the socket has since moved to.
73+
* Returns the target workflowId, or null when no target could be resolved
74+
* (or, for the Redis manager, when the removal failed).
7075
*/
7176
removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise<string | null>
7277

0 commit comments

Comments
 (0)