@@ -36,30 +36,13 @@ import * as awarenessProtocol from 'y-protocols/awareness'
3636import * as syncProtocol from 'y-protocols/sync'
3737import * as Y from 'yjs'
3838import { resolveAvatarUrl } from '@/handlers/avatar'
39+ import { fetchFileDocSeed } from '@/handlers/file-doc-seed'
3940import { resolveRoomJoinAuth } from '@/handlers/room-join-auth'
4041import type { AuthenticatedSocket } from '@/middleware/auth'
4142import type { IRoomManager } from '@/rooms'
4243
4344const logger = createLogger ( 'FileDocHandlers' )
4445
45- /**
46- * How long an elected seeder has to import the initial content before the server
47- * re-elects another client. Seeding is a local, synchronous editor write, so this
48- * only trips when a seeder never completes it (a bug, or a client withholding the
49- * seed) — it keeps the document from staying empty for the rest of the room.
50- */
51- const SEED_DEADLINE_MS = 10_000
52-
53- /**
54- * Max times the room re-offers seeding to its owners after every one of them has
55- * missed a deadline. Without this, a sole client whose seed *fetch fails* (not just
56- * slow) is added to `triedSeeders`, re-election finds no un-tried owner, and the
57- * document is left permanently empty until that client reloads. Each exhausted round
58- * clears `triedSeeders` and re-offers, giving a transient failure a bounded number of
59- * retries (~MAX × deadline) before the room genuinely gives up.
60- */
61- const MAX_SEED_ROUNDS = 3
62-
6346/** A socket's presence ownership within a room. */
6447interface FileDocOwner {
6548 /**
@@ -78,23 +61,15 @@ interface FileDocOwner {
7861}
7962
8063interface FileDocRoom {
81- /** The `workspace_files.id` this room edits (for seed-request payloads) . */
64+ /** The `workspace_files.id` this room edits. */
8265 fileId : string
8366 doc : Y . Doc
8467 awareness : awarenessProtocol . Awareness
8568 /** socketId → its presence ownership. */
8669 owners : Map < string , FileDocOwner >
87- /** The socket currently elected to seed initial content, or `null`. */
88- seederSocketId : string | null
89- /** Deadline timer for the current seeder to complete the seed, or `null`. */
90- seedTimer : ReturnType < typeof setTimeout > | null
91- /** Sockets that were elected but failed to seed within the deadline; skipped
92- * on re-election so a single stuck/withholding client can't block the room. */
93- triedSeeders : Set < string >
94- /** Count of full re-offer rounds spent after every owner missed a deadline;
95- * bounds the retry loop (see {@link MAX_SEED_ROUNDS}) so a permanently-broken
96- * seed doesn't reset forever. */
97- seedRounds : number
70+ /** True once the server-side seed fetch has started, so concurrent joins don't each fetch.
71+ * Reset on a fetch FAILURE so a later join can retry (a genuinely empty file stays empty). */
72+ serverSeedStarted : boolean
9873}
9974
10075/** Live documents keyed by Socket.IO room name. Module-global: one Y.Doc per file. */
@@ -183,73 +158,51 @@ function awarenessUpdateClientIds(update: Uint8Array): number[] {
183158 return ids
184159}
185160
186- function clearSeedTimer ( room : FileDocRoom ) {
187- if ( room . seedTimer !== null ) {
188- clearTimeout ( room . seedTimer )
189- room . seedTimer = null
190- }
191- }
192-
193161/**
194- * Drop a room's document + awareness (and its seed timer) once it has no owners,
195- * so an idle file holds no memory. A later joiner re-creates and re-seeds it.
162+ * Drop a room's document + awareness once it has no owners, so an idle file holds no memory.
163+ * A later joiner re-creates and re-seeds it from the file's current markdown .
196164 */
197165function destroyRoomIfIdle ( name : string ) {
198166 const room = fileDocRooms . get ( name )
199167 if ( ! room || room . owners . size > 0 ) return
200- clearSeedTimer ( room )
201168 room . awareness . destroy ( )
202169 room . doc . destroy ( )
203170 fileDocRooms . delete ( name )
204171}
205172
206173/**
207- * Elect a client to seed an unseeded document and ask it to import the stored
208- * markdown, if one is needed and not already assigned. Called after a join (to
209- * elect the newcomer), after the elected seeder leaves (to hand the role to a
210- * remaining client), and after a seed deadline lapses (to pass over a client that
211- * never seeded). Skips clients that already failed to seed, and arms a deadline so
212- * a stuck or withholding seeder can't leave the document empty for the whole room.
213- * A no-op once the document is seeded, a seeder is already assigned, or no
214- * un-tried client remains.
174+ * Seed a room's document server-side, once, on the first join: ask the app to build the seed (the
175+ * file's current markdown → Yjs, through the exact editor engine) and apply it, which relays the
176+ * content to every connected client via `doc.on('update')`. No client is elected to import content.
177+ *
178+ * `isDocSeeded` is the sufficient guard: content only ever reaches the doc alongside the seed flag
179+ * (this seed, or a client's offline fallback), so an unseeded doc is genuinely empty and safe to seed.
180+ * A genuinely empty/missing file returns `null` (a read error throws instead), so still set the flag —
181+ * an empty doc must reach readiness, not wait forever. After the fetch, re-check the room is still
182+ * live and unseeded (an owner may have left, or a client seeded it, while the fetch was in flight).
183+ *
184+ * Recovery on failure is deliberately simple — no in-room retry loop: a single attempt bounded by a
185+ * timeout shorter than the client's readiness deadline, then release the guard. A transient failure
186+ * is re-attempted by the next join/reconnect; a persistent one lets the connected client's readiness
187+ * deadline lapse into its read-only fallback. (An in-room backoff retry can outlast that client
188+ * deadline, so it would keep trying a doc the client has already given up on — worse, not better.)
215189 */
216- function electSeederIfNeeded ( io : Server , room : FileDocRoom ) {
217- if ( room . seederSocketId !== null || isDocSeeded ( room . doc ) ) return
218-
219- let elected : string | null = null
220- for ( const socketId of room . owners . keys ( ) ) {
221- if ( ! room . triedSeeders . has ( socketId ) ) {
222- elected = socketId
223- break
224- }
190+ async function ensureServerSeed (
191+ name : string ,
192+ room : FileDocRoom ,
193+ workspaceId : string
194+ ) : Promise < void > {
195+ if ( room . serverSeedStarted || isDocSeeded ( room . doc ) ) return
196+ room . serverSeedStarted = true
197+ try {
198+ const update = await fetchFileDocSeed ( workspaceId , room . fileId )
199+ if ( fileDocRooms . get ( name ) !== room || isDocSeeded ( room . doc ) ) return
200+ if ( update ) Y . applyUpdate ( room . doc , update )
201+ else room . doc . getMap ( FILE_DOC_SEED . configMap ) . set ( FILE_DOC_SEED . flag , true )
202+ } catch ( error ) {
203+ logger . warn ( `Server seed failed for file ${ room . fileId } (workspace ${ workspaceId } )` , error )
204+ room . serverSeedStarted = false
225205 }
226- if ( elected === null ) return
227-
228- room . seederSocketId = elected
229- io . to ( elected ) . emit ( FILE_DOC_EVENTS . SEED_REQUEST , { fileId : room . fileId } )
230-
231- clearSeedTimer ( room )
232- room . seedTimer = setTimeout ( ( ) => {
233- room . seedTimer = null
234- // Only act if this election is still the pending one and it never seeded.
235- if ( room . seederSocketId !== elected || isDocSeeded ( room . doc ) ) return
236- room . triedSeeders . add ( elected )
237- room . seederSocketId = null
238- electSeederIfNeeded ( io , room )
239- // If that left no seeder (every current owner has now been tried) while the doc is still
240- // unseeded and owners remain, re-offer the whole room a bounded number of times — otherwise a
241- // sole client whose seed fetch failed leaves the document permanently empty.
242- if (
243- room . seederSocketId === null &&
244- ! isDocSeeded ( room . doc ) &&
245- room . owners . size > 0 &&
246- room . seedRounds < MAX_SEED_ROUNDS
247- ) {
248- room . seedRounds += 1
249- room . triedSeeders . clear ( )
250- electSeederIfNeeded ( io , room )
251- }
252- } , SEED_DEADLINE_MS )
253206}
254207
255208/**
@@ -272,16 +225,11 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
272225 doc,
273226 awareness,
274227 owners : new Map ( ) ,
275- seederSocketId : null ,
276- seedTimer : null ,
277- triedSeeders : new Set ( ) ,
278- seedRounds : 0 ,
228+ serverSeedStarted : false ,
279229 }
280230 fileDocRooms . set ( name , room )
281231
282232 doc . on ( 'update' , ( update : Uint8Array , origin : unknown ) => {
283- // Once the document is seeded, the seed deadline is moot — cancel it.
284- if ( room . seedTimer !== null && isDocSeeded ( doc ) ) clearSeedTimer ( room )
285233 const encoder = encoding . createEncoder ( )
286234 encoding . writeVarUint ( encoder , FILE_DOC_MESSAGE_TYPE . SYNC )
287235 syncProtocol . writeUpdate ( encoder , update )
@@ -370,8 +318,8 @@ function handleMessage(socket: AuthenticatedSocket, data: unknown) {
370318
371319/**
372320 * Remove a socket from its file-doc room: clear its awareness state (so its caret
373- * disappears for everyone else), hand off the seeder role if it left before
374- * seeding, and drop the room's document when the last collaborator leaves.
321+ * disappears for everyone else) and drop the room's document when the last
322+ * collaborator leaves.
375323 * Exported for the disconnect handler; safe to call for a socket in no room.
376324 */
377325export function cleanupFileDocForSocket ( socketId : string , io : Server , endOfLife = false ) : void {
@@ -401,13 +349,6 @@ export function cleanupFileDocForSocket(socketId: string, io: Server, endOfLife
401349 broadcastFileDocPresence ( io , name , room )
402350 }
403351
404- // Hand off the seeder role: if the elected seeder left before it seeded, elect
405- // a remaining client so the document doesn't stay permanently empty.
406- if ( room . seederSocketId === socketId ) {
407- room . seederSocketId = null
408- electSeederIfNeeded ( io , room )
409- }
410-
411352 destroyRoomIfIdle ( name )
412353}
413354
@@ -519,10 +460,6 @@ export function setupWorkspaceFileDocHandlers(
519460 awarenessProtocol . removeAwarenessStates ( entry . awareness , [ owner . clientId ] , null )
520461 socketToRoomName . delete ( otherSid )
521462 io . in ( otherSid ) . socketsLeave ( name )
522- // If the evicted socket held the seeder role, release it so the election at the end of
523- // this join re-elects (electSeederIfNeeded no-ops while seederSocketId is set) — otherwise
524- // an unseeded document would stay empty until the seed deadline expires.
525- if ( entry . seederSocketId === otherSid ) entry . seederSocketId = null
526463 }
527464
528465 // Only now that the rebind is guaranteed to succeed, leave a previously-joined document if
@@ -568,7 +505,9 @@ export function setupWorkspaceFileDocHandlers(
568505 socket . emit ( FILE_DOC_EVENTS . MESSAGE , encoding . toUint8Array ( awarenessEncoder ) )
569506 }
570507
571- electSeederIfNeeded ( io , entry )
508+ // Seed the document server-side (once). Fire-and-forget: the join completes immediately and
509+ // the seed relays to this socket via `doc.on('update')` the moment it lands.
510+ if ( authorized . workspaceId ) void ensureServerSeed ( name , entry , authorized . workspaceId )
572511
573512 logger . info ( `User ${ userId } joined file-doc room ${ fileId } ` )
574513 } catch ( error ) {
0 commit comments