Skip to content

Commit b302ab5

Browse files
authored
fix(file-editor): recover collaboration after reconnect (#7491)
* fix(file-editor): recover collaboration after reconnect * fix(file-editor): cover provider protocol edge cases
1 parent 8804d5d commit b302ab5

10 files changed

Lines changed: 675 additions & 234 deletions

File tree

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

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,13 @@ describe('setupWorkspaceFileDocHandlers', () => {
233233
)
234234
})
235235

236-
it('rejects a payload missing the file id or client id before authorizing', async () => {
236+
it('rejects a payload with a missing or out-of-range client id before authorizing', async () => {
237237
const { io } = createIo()
238238
const { socket, handlers } = setup('socket-1', io)
239239

240240
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: '', clientId: 1 })
241241
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1' })
242+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 0x1_0000_0000 })
242243

243244
expect(socket.emit).toHaveBeenCalledWith(
244245
FILE_DOC_EVENTS.JOIN_ERROR,
@@ -545,6 +546,10 @@ describe('setupWorkspaceFileDocHandlers', () => {
545546

546547
expect(socket.join).toHaveBeenCalledWith(ROOM_NAME)
547548
expect(joinSuccessFileId(socket)).toBe('file-1')
549+
expect(socket.emit).toHaveBeenCalledWith(
550+
FILE_DOC_EVENTS.JOIN_SUCCESS,
551+
expect.objectContaining({ fileId: 'file-1', clientId: 1 })
552+
)
548553

549554
// A binary sync-step-1 message (type tag 0) is sent to kick off the handshake.
550555
const syncMessage = socket.emit.mock.calls.find(
@@ -843,7 +848,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
843848
expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(2)
844849
})
845850

846-
it('relays a document update to the rest of the room, excluding the sender', async () => {
851+
it('relays a document update to every provider, including siblings on the sender socket', async () => {
847852
const { io, sent } = createIo()
848853
const a = setup('socket-a', io)
849854
const b = setup('socket-b', io)
@@ -860,7 +865,7 @@ describe('setupWorkspaceFileDocHandlers', () => {
860865

861866
const relayed = sent.find((m) => m.event === FILE_DOC_EVENTS.MESSAGE)
862867
expect(relayed?.target).toBe(ROOM_NAME)
863-
expect(relayed?.except).toBe('socket-a')
868+
expect(relayed?.except).toBeUndefined()
864869
expect((relayed?.payload as Uint8Array)[0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC)
865870
})
866871

@@ -959,6 +964,35 @@ describe('setupWorkspaceFileDocHandlers', () => {
959964
expect(relayedFor(999)).toBeUndefined()
960965
})
961966

967+
it('accepts concurrent joins from co-mounted providers for the same file', async () => {
968+
let resolveFirstAuth: (value: unknown) => void = () => {}
969+
mockAuthorizeRoom.mockReturnValueOnce(
970+
new Promise((resolve) => {
971+
resolveFirstAuth = resolve
972+
})
973+
)
974+
const { io } = createIo()
975+
const { socket, handlers } = setup('socket-a', io)
976+
977+
const first = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 500 })
978+
await Promise.resolve()
979+
const second = handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 501 })
980+
await second
981+
982+
resolveFirstAuth({
983+
allowed: true,
984+
status: 200,
985+
workspaceId: 'ws-1',
986+
workspacePermission: 'write',
987+
})
988+
await first
989+
990+
const acceptedClientIds = socket.emit.mock.calls
991+
.filter(([event]) => event === FILE_DOC_EVENTS.JOIN_SUCCESS)
992+
.map(([, payload]) => (payload as { clientId: number }).clientId)
993+
expect(acceptedClientIds).toEqual(expect.arrayContaining([500, 501]))
994+
})
995+
962996
it('preserves the existing caret when a rebind to a foreign client id is rejected', async () => {
963997
const { io, sent } = createIo()
964998
const { frame: awFrame } = awarenessFrame(10, 'A')

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

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -192,15 +192,19 @@ const fileDocRooms = new Map<string, FileDocRoom>()
192192
/** socketId → its current file-doc room name (a socket edits at most one doc). */
193193
const socketToRoomName = new Map<string, string>()
194194
/**
195-
* socketId → a monotonic join generation. A JOIN bumps it on arrival and, after
196-
* the async authorization, proceeds only if the generation is still its own — so
197-
* a newer JOIN (a fast document switch) or a disconnect (which drops the entry in
198-
* cleanup) that occurred during authorization aborts the now-stale JOIN. Without
199-
* this, an out-of-order authorize completion could bind the socket to the wrong
200-
* document, or a disconnect-during-authorize could register a dead socket and
201-
* leak its room.
195+
* socketId → a monotonic file-intent generation. Switching files or leaving the
196+
* intended file advances it; co-mounted providers joining the same file share it.
197+
* After async authorization, a join proceeds only while its generation is current,
198+
* preventing an out-of-order completion from binding the socket to the wrong file.
202199
*/
203200
const joinGeneration = new Map<string, number>()
201+
const MAX_YJS_CLIENT_ID = 0xffff_ffff
202+
203+
function isYjsClientId(value: unknown): value is number {
204+
return (
205+
typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_YJS_CLIENT_ID
206+
)
207+
}
204208

205209
interface AwarenessChange {
206210
added: number[]
@@ -227,10 +231,8 @@ function originSocketId(origin: unknown): string | null {
227231
* The transaction origin stamped on an agent-streamed frame (a {@link FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST}
228232
* apply). A non-string sentinel, so `originSocketId` returns `null` for it and the update never triggers
229233
* `edited`/`schedulePersist` (the copilot's final `edit_content` write is the durable persist). Unlike a
230-
* client edit, an agent frame is broadcast to the WHOLE room (its originating socket is NOT excluded), so a
231-
* second {@link FileDocProvider} on the same socket — e.g. the chat preview alongside the Files editor —
232-
* also receives the mid-stream ops. The emitting provider no-ops on its own echo (the ops are already
233-
* applied locally), so broadcasting back to the sender is harmless.
234+
* client edit, it is marked so peers do not treat it as a durable user edit. The emitting provider no-ops
235+
* on its own echo because the operations are already applied locally.
234236
*/
235237
const AGENT_SYNC_ORIGIN = Symbol('file-doc-agent-sync')
236238

@@ -783,7 +785,7 @@ async function mergeMarkdownIntoRoom(
783785
/**
784786
* Get (or lazily create) the authoritative document for a room, wiring the two
785787
* relay handlers exactly once: document updates and awareness changes are
786-
* broadcast to the room, excluding the origin socket (it already applied them).
788+
* broadcast to the room.
787789
*/
788790
function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
789791
const name = roomName(ref)
@@ -821,18 +823,12 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
821823
const encoder = encoding.createEncoder()
822824
encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC)
823825
syncProtocol.writeUpdate(encoder, update)
824-
// Fan out to THIS task's clients only (excluding the origin socket if local — a user edit OR an
825-
// agent-streamed frame). Cross-task delivery rides the shared stream — every task's tailer applies +
826-
// runs its own local fan-out.
827-
// A client edit excludes its own sender socket (echo suppression). An agent frame broadcasts to the
828-
// WHOLE room — no socket excluded — so a same-socket sibling provider (chat preview + Files editor)
829-
// stays live mid-stream; the emitting provider no-ops on its own echo.
830-
broadcastLocal(
831-
io,
832-
name,
833-
encoding.toUint8Array(encoder),
834-
origin === AGENT_SYNC_ORIGIN ? null : originSocketId(origin)
835-
)
826+
// Fan out to every client on THIS task, including the origin socket. One shared Socket.IO connection
827+
// can host multiple providers for this file; excluding the whole socket would strand the sibling
828+
// provider's distinct Y.Doc. Yjs updates are idempotent, and the originating provider applies its
829+
// echo with the provider as transaction origin, so it does not send the update again. Cross-task
830+
// delivery rides the shared stream, where every task's tailer runs its own local fan-out.
831+
broadcastLocal(io, name, encoding.toUint8Array(encoder), null)
836832
// Share every locally-originated update to the stream so peers converge. Skip updates that already
837833
// came FROM the stream (REDIS_ORIGIN / REDIS_SNAPSHOT_ORIGIN / REDIS_AGENT_ORIGIN) and SEED_ORIGIN —
838834
// the seed is published EXPLICITLY and AWAITED under the seed lock (so it lands before the lock
@@ -898,12 +894,15 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
898894
function emitJoinError(
899895
socket: AuthenticatedSocket,
900896
fileId: unknown,
897+
clientId: unknown,
901898
error: string,
902899
code: string,
903900
retryable: boolean
904901
) {
902+
const normalizedClientId = isYjsClientId(clientId) ? clientId : undefined
905903
socket.emit(FILE_DOC_EVENTS.JOIN_ERROR, {
906904
fileId: typeof fileId === 'string' ? fileId : '',
905+
clientId: normalizedClientId,
907906
error,
908907
code,
909908
retryable,
@@ -1113,30 +1112,47 @@ export function setupWorkspaceFileDocHandlers(
11131112
const userName = socket.userName
11141113

11151114
if (!userId || !userName) {
1116-
emitJoinError(socket, fileId, 'Authentication required', 'AUTHENTICATION_REQUIRED', false)
1115+
emitJoinError(
1116+
socket,
1117+
fileId,
1118+
clientId,
1119+
'Authentication required',
1120+
'AUTHENTICATION_REQUIRED',
1121+
false
1122+
)
11171123
return
11181124
}
11191125
if (!roomManager.isReady()) {
1120-
emitJoinError(socket, fileId, 'Realtime unavailable', 'ROOM_MANAGER_UNAVAILABLE', true)
1126+
emitJoinError(
1127+
socket,
1128+
fileId,
1129+
clientId,
1130+
'Realtime unavailable',
1131+
'ROOM_MANAGER_UNAVAILABLE',
1132+
true
1133+
)
11211134
return
11221135
}
11231136
if (
11241137
typeof fileId !== 'string' ||
11251138
fileId.length === 0 ||
1126-
// A Yjs clientID is a uint32; reject NaN/Infinity/negative/non-integer so a malformed id
1127-
// can't become a bogus ownership key.
1128-
!Number.isInteger(clientId) ||
1129-
clientId < 0
1139+
// A Yjs clientID is a uint32; reject malformed values before they can become ownership keys.
1140+
!isYjsClientId(clientId)
11301141
) {
1131-
emitJoinError(socket, fileId, 'Invalid join payload', 'INVALID_PAYLOAD', false)
1142+
emitJoinError(socket, fileId, clientId, 'Invalid join payload', 'INVALID_PAYLOAD', false)
11321143
return
11331144
}
11341145

1135-
// Claim this JOIN's generation before the async authorize below, and record the file the
1136-
// socket now intends to edit so a leave for it can cancel this join if it's still in-flight.
1137-
generation = (joinGeneration.get(socket.id) ?? 0) + 1
1138-
joinGeneration.set(socket.id, generation)
1139-
currentFileId = fileId
1146+
// A generation represents the socket's intended FILE, not an individual provider. Co-mounted
1147+
// providers for the same file must be allowed to join concurrently; switching files advances the
1148+
// generation so every in-flight join for the old file is cancelled together.
1149+
if (currentFileId !== fileId) {
1150+
generation = (joinGeneration.get(socket.id) ?? 0) + 1
1151+
joinGeneration.set(socket.id, generation)
1152+
currentFileId = fileId
1153+
} else {
1154+
generation = joinGeneration.get(socket.id) ?? 0
1155+
}
11401156

11411157
const room = fileDocRoom(fileId)
11421158
const name = roomName(room)
@@ -1153,7 +1169,7 @@ export function setupWorkspaceFileDocHandlers(
11531169
accessDenied: 'Access denied to file',
11541170
},
11551171
emitError: ({ error, code, retryable }) =>
1156-
emitJoinError(socket, fileId, error, code, retryable),
1172+
emitJoinError(socket, fileId, clientId, error, code, retryable),
11571173
})
11581174
if (!authorized) return
11591175

@@ -1190,7 +1206,7 @@ export function setupWorkspaceFileDocHandlers(
11901206
logger.warn(
11911207
`User ${userId} lost write access to file ${fileId} before the join completed`
11921208
)
1193-
emitJoinError(socket, fileId, 'Access denied to file', 'ACCESS_DENIED', false)
1209+
emitJoinError(socket, fileId, clientId, 'Access denied to file', 'ACCESS_DENIED', false)
11941210
return
11951211
}
11961212

@@ -1219,7 +1235,14 @@ export function setupWorkspaceFileDocHandlers(
12191235
const owner = clientMap.get(clientId)
12201236
if (owner === undefined) continue
12211237
if (owner.userId !== userId) {
1222-
emitJoinError(socket, fileId, 'Client id already in use', 'CLIENT_ID_IN_USE', false)
1238+
emitJoinError(
1239+
socket,
1240+
fileId,
1241+
clientId,
1242+
'Client id already in use',
1243+
'CLIENT_ID_IN_USE',
1244+
false
1245+
)
12231246
return
12241247
}
12251248
// Same user reclaiming its client id on a stale prior socket: evict just THAT clientID's
@@ -1268,7 +1291,11 @@ export function setupWorkspaceFileDocHandlers(
12681291
// Name the document this room holds, so a client that still carries a DIFFERENT one (its room
12691292
// outlived by a document rebuilt in its place) can refuse to merge instead of unioning two
12701293
// documents into the file twice over. Read after readiness — before it, the room has no doc yet.
1271-
socket.emit(FILE_DOC_EVENTS.JOIN_SUCCESS, { fileId, docId: docIdOf(entry.doc) })
1294+
socket.emit(FILE_DOC_EVENTS.JOIN_SUCCESS, {
1295+
fileId,
1296+
clientId,
1297+
docId: docIdOf(entry.doc),
1298+
})
12721299
// Server-authenticated roster → everyone in the room, including this joiner.
12731300
broadcastFileDocPresence(io, name, entry)
12741301

@@ -1321,7 +1348,7 @@ export function setupWorkspaceFileDocHandlers(
13211348
(generation !== undefined && joinGeneration.get(socket.id) !== generation)
13221349
)
13231350
return
1324-
emitJoinError(socket, fileId, 'Failed to join file document', 'JOIN_FAILED', true)
1351+
emitJoinError(socket, fileId, clientId, 'Failed to join file document', 'JOIN_FAILED', true)
13251352
}
13261353
})
13271354

0 commit comments

Comments
 (0)