@@ -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). */
193193const 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 */
203200const 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
205209interface 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 */
235237const 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 */
788790function 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 {
898894function 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