Skip to content

Commit f09aa17

Browse files
committed
fix(realtime): file-doc rebind must not drop the current doc or leave a writable ghost
Two Cursor findings on the file-doc client-id ownership rebind: - On a document switch, the prior room was left BEFORE the ownership check, so a CLIENT_ID_IN_USE rejection dropped the socket from the old doc without joining the new one (contradicting its own comment). Run the ownership check first, and leave the previous doc only once the rebind is guaranteed to succeed. - Reclaiming a client id removed the stale prior socket from owners + awareness only; its socketToRoomName + Socket.IO membership remained, and handleMessage's SYNC path gates on socketToRoomName (not owners), so it stayed able to write document frames until disconnect. Fully evict the reclaimed socket. + 2 tests.
1 parent 2c40b3f commit f09aa17

2 files changed

Lines changed: 74 additions & 18 deletions

File tree

apps/realtime/src/handlers/file-doc.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,21 @@ interface SentMessage {
3333
/** An `io` mock that records every server-originated emit with its target/except. */
3434
function createIo() {
3535
const sent: SentMessage[] = []
36+
/** Records `io.in(socketId).socketsLeave(room)` — a socket forced out of a room from outside. */
37+
const left: { socketId: string; room: string }[] = []
3638
const to = vi.fn((target: string) => ({
3739
except: (exclude: string) => ({
3840
emit: (event: string, payload: unknown) =>
3941
sent.push({ target, except: exclude, event, payload }),
4042
}),
4143
emit: (event: string, payload: unknown) => sent.push({ target, event, payload }),
4244
}))
43-
return { io: { to } as unknown as IRoomManager['io'], sent }
45+
const inFn = vi.fn((socketId: string) => ({
46+
socketsLeave: (room: string) => {
47+
left.push({ socketId, room })
48+
},
49+
}))
50+
return { io: { to, in: inFn } as unknown as IRoomManager['io'], sent, left }
4451
}
4552

4653
/** Every socket id a test created, so `afterEach` can drop their rooms without a
@@ -583,6 +590,49 @@ describe('setupWorkspaceFileDocHandlers', () => {
583590
expect(sent.find((m) => m.event === FILE_DOC_EVENTS.SEED_REQUEST)?.target).toBe('socket-b')
584591
})
585592

593+
it('fully evicts a reclaimed prior socket so it can no longer write to the doc', async () => {
594+
const { io, sent, left } = createIo()
595+
const a = setup('socket-a', io)
596+
await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 })
597+
const b = setup('socket-b', io) // same default user-1
598+
await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 7 }) // reclaims client id 7
599+
600+
// The stale prior socket is forced out of the Socket.IO room...
601+
expect(left).toContainEqual({ socketId: 'socket-a', room: ROOM_NAME })
602+
603+
// ...and its room mapping is cleared, so a later document (SYNC) frame from it is dropped
604+
// (handleMessage's SYNC path gates on socketToRoomName): nothing is applied or relayed.
605+
sent.length = 0
606+
const doc = new Y.Doc()
607+
doc.getText('t').insert(0, 'x')
608+
const updateFrame = frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
609+
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(doc))
610+
)
611+
a.handlers[FILE_DOC_EVENTS.MESSAGE](updateFrame)
612+
expect(sent.some((m) => m.event === FILE_DOC_EVENTS.MESSAGE)).toBe(false)
613+
})
614+
615+
it('does not drop the current document when a switch is rejected for a foreign client id', async () => {
616+
const { io } = createIo()
617+
const a = setup('socket-a', io) // user-1
618+
const other = setup('socket-c', io, { userId: 'user-b' })
619+
await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 10 }) // a owns 10 in file-1
620+
await other.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 99 }) // user-b owns 99 in file-2
621+
a.socket.leave.mockClear()
622+
a.socket.join.mockClear()
623+
624+
// a tries to switch to file-2 but requests client id 99, owned by a DIFFERENT user → reject.
625+
await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 99 })
626+
627+
expect(a.socket.emit).toHaveBeenCalledWith(
628+
FILE_DOC_EVENTS.JOIN_ERROR,
629+
expect.objectContaining({ code: 'CLIENT_ID_IN_USE' })
630+
)
631+
// The rejected switch must leave file-1 intact — a is not torn out of its current document.
632+
expect(a.socket.leave).not.toHaveBeenCalledWith('workspace-file-doc:file-1')
633+
expect(a.socket.join).not.toHaveBeenCalledWith('workspace-file-doc:file-2')
634+
})
635+
586636
it('re-elects a new seeder when the elected one misses the seed deadline', async () => {
587637
const { io, sent } = createIo()
588638
const a = setup('socket-a', io)

apps/realtime/src/handlers/file-doc.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -457,33 +457,39 @@ export function setupWorkspaceFileDocHandlers(
457457
// here would leak a dead socket's room or bind the socket to the wrong document.
458458
if (socket.disconnected || joinGeneration.get(socket.id) !== generation) return
459459

460-
// Switched documents on the same socket — leave the previous one first (a
461-
// socket edits at most one document). A duplicate join of the SAME room
462-
// falls through and simply re-runs the sync handshake, idempotently.
463-
const currentName = socketToRoomName.get(socket.id)
464-
if (currentName && currentName !== name) {
465-
socket.leave(currentName)
466-
cleanupFileDocForSocket(socket.id, io)
467-
}
468-
469460
const entry = getOrCreateRoom(io, room)
470461

471-
// A client id must be owned by at most one user, or a peer could bind an
472-
// active collaborator's id and pass the per-frame ownership check to
473-
// spoof/clear its caret. Distinguish a reconnect from a spoof by the owning
474-
// user: the same user reclaiming its own client id (a dropped socket
475-
// reconnecting reuses the Yjs client id, and its prior socket may not be
476-
// cleaned up yet) takes over the stale binding; a DIFFERENT user is
477-
// rejected. This runs BEFORE any state mutation below, so a rejected rebind
478-
// leaves the socket's existing binding and caret untouched.
462+
// A client id must be owned by at most one user, or a peer could bind an active
463+
// collaborator's id and pass the per-frame ownership check to spoof/clear its caret.
464+
// Distinguish a reconnect from a spoof by the owning user: the same user reclaiming its
465+
// own client id (a dropped socket reconnecting reuses the Yjs client id, and its prior
466+
// socket may not be cleaned up yet) takes over the stale binding; a DIFFERENT user is
467+
// rejected. This runs BEFORE any teardown of the socket's current binding below, so a
468+
// rejected rebind — even during a document switch — leaves the socket's existing document
469+
// and caret untouched.
479470
for (const [otherSid, owner] of entry.owners) {
480471
if (owner.clientId !== clientId || otherSid === socket.id) continue
481472
if (owner.userId !== userId) {
482473
emitJoinError(socket, fileId, 'Client id already in use', 'CLIENT_ID_IN_USE', false)
483474
return
484475
}
476+
// Fully evict the stale prior socket of the same user — owner + caret AND its room
477+
// mapping + Socket.IO membership — so it can no longer send document (sync) frames:
478+
// handleMessage's SYNC path gates on socketToRoomName, not owners. Done inline rather
479+
// than via cleanupFileDocForSocket, which could destroyRoomIfIdle the room we're joining.
485480
entry.owners.delete(otherSid)
486481
awarenessProtocol.removeAwarenessStates(entry.awareness, [owner.clientId], null)
482+
socketToRoomName.delete(otherSid)
483+
io.in(otherSid).socketsLeave(name)
484+
}
485+
486+
// Only now that the rebind is guaranteed to succeed, leave a previously-joined document if
487+
// switching (a socket edits at most one). A duplicate join of the SAME room falls through
488+
// and simply re-runs the sync handshake, idempotently.
489+
const currentName = socketToRoomName.get(socket.id)
490+
if (currentName && currentName !== name) {
491+
socket.leave(currentName)
492+
cleanupFileDocForSocket(socket.id, io)
487493
}
488494

489495
// Accepted: a same socket rebinding to a NEW client id clears its old caret

0 commit comments

Comments
 (0)