@@ -62,24 +62,39 @@ function normalizeCellSelection(cell: unknown): TableCellSelection | undefined {
6262 * only because a workflow room's name equals its id).
6363 */
6464export 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