From 80b72e4d69f626d4633645db7a2b559df2be98ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 15 Apr 2026 13:27:47 +0200 Subject: [PATCH] wip another attempt for sync --- .../encryption/oo-bridge/OOEditor.tsx | 649 +++++++++++------- .../encryption/oo-bridge/encryptedRelay.ts | 44 +- src/frontend/servers/collaboration/relay.ts | 18 +- 3 files changed, 459 insertions(+), 252 deletions(-) diff --git a/src/frontend/apps/drive/src/features/encryption/oo-bridge/OOEditor.tsx b/src/frontend/apps/drive/src/features/encryption/oo-bridge/OOEditor.tsx index 5404ec46..f9882519 100644 --- a/src/frontend/apps/drive/src/features/encryption/oo-bridge/OOEditor.tsx +++ b/src/frontend/apps/drive/src/features/encryption/oo-bridge/OOEditor.tsx @@ -103,7 +103,8 @@ type EditorState = | 'mounting' | 'ready' | 'error' - | 'stale-resyncing'; + | 'stale-resyncing' + | 'oo-crashed'; // Only these OO message types are allowed in from peers. Anything else (rpc, // executeCommand, documentOpen, pluginEvent, forceSave, …) would be a way for @@ -149,8 +150,11 @@ export const OOEditor = ({ item }: OOEditorProps) => { const relayRef = useRef(null); const containerRef = useRef(null); const scriptLoadedRef = useRef(false); - // Connected peers with edit capability (not including self). Used for save leader election. - const editorPeersRef = useRef>(new Set()); + // Connected peers with edit capability (not including self). Keyed + // by userId, value is the earliest `joinedAt` we've seen for that + // user — so leader election can tiebreak across multiple same-user + // tabs by comparing (userId, joinedAt) lexicographically. + const editorPeersRef = useRef>(new Map()); // Reference to the inner OO iframe's window — used to register media in // g_oDocumentUrls when inbound saveChanges envelopes carry inline images. const innerWindowRef = useRef(null); @@ -170,89 +174,83 @@ export const OOEditor = ({ item }: OOEditorProps) => { * still wait this long before feeding it the first replayed event, * so its internal state machine has time to settle. */ - const DRAIN_PRE_DELAY_MS = 1500; - /** - * Max time we wait for a missing `changesIndex` to arrive before we - * give up and force-apply whatever is next in the reorder buffer. - */ - const REPLAY_GAP_TIMEOUT_MS = 5000; + const DRAIN_PRE_DELAY_MS = 0; /** - * Reorder buffer state for index-gated replay. We store the "start - * index" (`changesIndex - changes.length`) of every saveChanges we've - * seen but can't apply yet because its predecessor hasn't arrived. - * Keyed by start index. Non-saveChanges callbacks (cursor, lock, - * message, meta) don't go through this gate — they're applied as - * soon as the editor is ready. + * FIFO buffer of inbound `saveChanges` apply-closures collected while + * the post-ready pre-delay is still active. Flushed in arrival order + * when the pre-delay elapses. */ - const replayExpectedStartIdxRef = useRef(null); - type BufferedSaveChanges = { endIdx: number; apply: () => void }; - const replayBufferRef = useRef>(new Map()); - const replayGapTimerRef = useRef | null>( - null, - ); - /** - * One-shot pre-delay timer that releases replay after OO settles. - * While `replayPreDelayActive` is true, we buffer everything (even - * the first event) and flush on timer fire. - */ - const replayPreDelayActiveRef = useRef(true); + const replayBufferRef = useRef void>>([]); - const clearReplayGapTimer = useCallback(() => { - if (replayGapTimerRef.current) { - clearTimeout(replayGapTimerRef.current); - replayGapTimerRef.current = null; - } + /** + * Pending verifications: one entry per inbound `saveChanges` we've + * forwarded to OO but haven't yet confirmed landed on the + * collaborative history. The `asc_onApplyChanges` listener drains + * this on each apply and compares expected vs observed delta. + */ + const pendingApplyVerificationsRef = useRef< + Array<{ + expected: number; + before: number; + message: Record; + }> + >([]); + + /** + * Once this flips to true, every outbound event callback (saveChanges, + * cursor, lock, message, meta) short-circuits before touching the + * relay. It's flipped whenever we detect the OO editor has entered + * an inconsistent state (crash overlay shown). The underlying OO + * instance may keep running its autosave loop and emitting edits + * built from its corrupted in-memory state — we must NOT let those + * leak to peers, or they'd crash everyone in the room. + */ + const outgoingSilencedRef = useRef(false); + + /** + * Deterministic leader election: lexicographically lowest + * (userId, joinedAt) among connected editors wins. Readers never + * lead. Every client computes this independently — no coordination. + * Exposed as a ref so both `initCheckpointing` and the beforeunload + * handler can call it. + */ + const isSaveLeaderRef = useRef<() => boolean>(() => false); + + /** + * Enter the crash-recovery state: silence all outbound events to + * the relay AND flip `state` to render the overlay. Use this + * instead of calling `setState('oo-crashed')` directly so we never + * forget to cut off outbound traffic. + */ + const enterCrashState = useCallback(() => { + outgoingSilencedRef.current = true; + setState('oo-crashed'); }, []); - const drainReplayBuffer = useCallback(() => { - if (replayPreDelayActiveRef.current) return; - // Apply as many buffered events as match the expected index, - // advancing the cursor to each applied event's endIdx. - while (replayExpectedStartIdxRef.current !== null) { - const expected = replayExpectedStartIdxRef.current; - const entry = replayBufferRef.current.get(expected); - if (!entry) break; - replayBufferRef.current.delete(expected); - clearReplayGapTimer(); - try { - entry.apply(); - } catch (e) { - console.warn('[replay] apply error', e); + /** + * Read `CollaborativeEditing.CoHistory.GetChangeCount()` from inside + * the OO iframe. Returns `null` if the accessor isn't available yet + * (editor still mounting, or torn down). + */ + const readCollabChangeCount = useCallback((): number | null => { + try { + const coHistory = + innerWindowRef.current?.AscCommon?.CollaborativeEditing?.CoHistory; + if (coHistory && typeof coHistory.GetChangeCount === 'function') { + return coHistory.GetChangeCount(); } - replayExpectedStartIdxRef.current = entry.endIdx; + } catch { + /* ignore */ } - // Still buffered but no match — arm the gap timeout. - if (replayBufferRef.current.size > 0 && !replayGapTimerRef.current) { - replayGapTimerRef.current = setTimeout(() => { - const keys = Array.from(replayBufferRef.current.keys()).sort( - (a, b) => a - b, - ); - if (keys.length === 0) { - replayGapTimerRef.current = null; - return; - } - const firstKey = keys[0]; - const entry = replayBufferRef.current.get(firstKey)!; - replayBufferRef.current.delete(firstKey); - console.log( - `[replay] gap timeout — force-applying startIdx=${firstKey} ` + - `endIdx=${entry.endIdx} ` + - `(expected=${replayExpectedStartIdxRef.current})`, - ); - replayGapTimerRef.current = null; - try { - entry.apply(); - } catch (e) { - console.warn('[replay] forced apply error', e); - } - // Advance the cursor past the force-applied event so the next - // drain round can pick up the events that follow. - replayExpectedStartIdxRef.current = entry.endIdx; - drainReplayBuffer(); - }, REPLAY_GAP_TIMEOUT_MS); - } - }, [clearReplayGapTimer]); + return null; + }, []); + /** + * One-shot pre-delay timer that releases replay after OO settles. + * While true, saveChanges are buffered; when it flips to false they + * flow straight through. + */ + const replayPreDelayActiveRef = useRef(true); /** * Entry point for every inbound `saveChanges`. Classifies against @@ -261,113 +259,59 @@ export const OOEditor = ({ item }: OOEditorProps) => { */ const handleIncomingSaveChanges = useCallback( (message: Record, applyFn: () => void) => { - const changes = (message as { changes?: unknown }).changes; - const count = Array.isArray(changes) ? changes.length : 0; - const endIdx = Number(message.changesIndex); - - // Envelopes with no usable index — apply without gating (rare). - if (!Number.isFinite(endIdx) || count === 0) { - if (replayPreDelayActiveRef.current) { - // Still holding the pre-delay: synthesize a key so it flushes - // alongside the other buffered events. - const syntheticKey = - (replayExpectedStartIdxRef.current ?? 0) - 1; - replayBufferRef.current.set(syntheticKey, { - endIdx: syntheticKey + 1, - apply: applyFn, - }); - return; - } - applyFn(); - return; - } - - const startIdx = endIdx - count; - - // Pre-delay: buffer everything. Seed `expected` from the - // smallest startIdx we've seen so the flush starts in order. + // We apply every inbound `saveChanges` in receive order, full stop. + // - Ordering is already guaranteed by the relay (TCP + our handler + // serialization in `encryptedRelay.ts`). + // - OO's own collaborative-edit engine handles any residual + // sequence bookkeeping. + // - Trying to arbitrate ordering ourselves caused false "stale" + // drops on sender-session resets and phantom gaps that never + // close. + // + // During the pre-delay window we still hold events back (so OO + // has a moment to settle after onDocumentContentReady), then + // flush them in arrival order when the timer fires. if (replayPreDelayActiveRef.current) { - if ( - replayExpectedStartIdxRef.current === null || - startIdx < replayExpectedStartIdxRef.current - ) { - replayExpectedStartIdxRef.current = startIdx; - } - replayBufferRef.current.set(startIdx, { endIdx, apply: applyFn }); + replayBufferRef.current.push(applyFn); return; } - - // First event ever after flush — accept and advance to its endIdx. - if (replayExpectedStartIdxRef.current === null) { - try { - applyFn(); - } catch (e) { - console.warn('[replay] first-event apply error', e); - } - replayExpectedStartIdxRef.current = endIdx; - drainReplayBuffer(); - return; + try { + applyFn(); + } catch (e) { + console.warn('[replay] apply error', e); } - - // In-order: apply and advance. - if (startIdx === replayExpectedStartIdxRef.current) { - try { - applyFn(); - } catch (e) { - console.warn('[replay] apply error', e); - } - replayExpectedStartIdxRef.current = endIdx; - drainReplayBuffer(); - return; - } - - // Stale or duplicate — sender's index went backwards. Drop it. - if (startIdx < replayExpectedStartIdxRef.current) { - console.warn( - `[replay] dropping stale/duplicate startIdx=${startIdx} ` + - `endIdx=${endIdx} expected=${replayExpectedStartIdxRef.current}`, - ); - return; - } - - // Ahead of the expected cursor — buffer until the gap is filled - // or the 5s gap timer forces a jump. - console.log( - `[replay] buffering out-of-order startIdx=${startIdx} ` + - `endIdx=${endIdx} expected=${replayExpectedStartIdxRef.current}`, - ); - replayBufferRef.current.set(startIdx, { endIdx, apply: applyFn }); - drainReplayBuffer(); }, - [drainReplayBuffer], + [], ); /** * Called once OO reports documentContentReady. Waits the pre-delay - * then releases buffered events through the index-gated path. + * then flushes the FIFO buffer in arrival order. */ const releaseReplayAfterPreDelay = useCallback(() => { console.log( `[replay] scheduling release after ${DRAIN_PRE_DELAY_MS} ms pre-delay ` + - `(buffered=${replayBufferRef.current.size})`, + `(buffered=${replayBufferRef.current.length})`, ); setTimeout(() => { - console.log('[replay] pre-delay elapsed — flushing buffer'); + console.log( + '[replay] pre-delay elapsed — flushing', + replayBufferRef.current.length, + 'buffered events in arrival order', + ); replayPreDelayActiveRef.current = false; setVerboseSends(true); - // If we gathered some events during pre-delay, the expected - // index is already seeded from the smallest startIdx we saw. - // Fast-forward it to match the smallest key in the buffer so - // the first drain round actually advances. - if (replayBufferRef.current.size > 0) { - const keys = Array.from(replayBufferRef.current.keys()).sort( - (a, b) => a - b, - ); - replayExpectedStartIdxRef.current = keys[0]; + const queue = replayBufferRef.current; + replayBufferRef.current = []; + for (const apply of queue) { + try { + apply(); + } catch (e) { + console.warn('[replay] flush apply error', e); + } } - drainReplayBuffer(); }, DRAIN_PRE_DELAY_MS); - }, [drainReplayBuffer]); + }, []); const canEdit = !!item.abilities?.partial_update; const mime = item.mimetype || ''; @@ -501,20 +445,82 @@ export const OOEditor = ({ item }: OOEditorProps) => { let cancelled = false; const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasUnsavedChanges()) { + // Only the save leader is responsible for persisting state — + // non-leader peers have no obligation to save, so closing + // their tab loses nothing that wouldn't be re-requested from + // the relay next time someone opens the doc. Suppressing the + // browser confirm on non-leaders avoids the misleading + // "unsaved changes" prompt when it's not true for this tab. + if (isSaveLeaderRef.current() && hasUnsavedChanges()) { e.preventDefault(); } }; const handleVisibilityChange = () => { - if (document.visibilityState === 'hidden' && hasUnsavedChanges()) { + if ( + document.visibilityState === 'hidden' && + isSaveLeaderRef.current() && + hasUnsavedChanges() + ) { forceSave().catch(() => {}); } }; + // Global uncaught-error listener for OO crashes. The shield around + // `Apply_Data` catches most collaborative-edit errors, but some + // escape via the post-apply recalc path (e.g. `Refresh_RecalcData2 + // is not a function`). When that happens, the editor becomes + // non-functional — we surface a recovery overlay so the user can + // hard-reload and pick up the latest saved state. + const isOOCrash = (src: string | undefined, msg: string): boolean => { + // Any uncaught error whose source is inside the OO sdkjs + // bundles is treated as a crash. Rationale: by the time a + // sdkjs function throws past its caller, OO's internal state + // is in an undefined condition. Whitelisting specific function + // names lets future unknown failure modes silently corrupt + // the editor. Known-benign cases (spell-check worker race, + // collab cursor on missing run, paragraph lock on replaced + // object) are caught and neutralised by explicit wrappers + // upstream, so they never reach this listener. + if (src && /\/sdkjs\/|sdk-all/.test(src)) return true; + // Fallback: also match stacks whose message mentions sdkjs + // symbols but whose `filename` was lost (cross-frame rethrow). + return /sdk-all|\bAsc(?:Common|Format|Word)\b|CDocument\.|CCollaborative/.test( + msg, + ); + }; + const handleOOError = (event: ErrorEvent) => { + if (cancelled) return; + const src = event.filename ?? ''; + const msg = String(event.error?.stack ?? event.message ?? ''); + if (!isOOCrash(src, msg)) return; + console.error('[OOEditor] uncaught OO crash — showing recovery', { + src, + msg, + }); + enterCrashState(); + }; + window.addEventListener('error', handleOOError); + const init = async () => { try { if (!item.url || !user) return; + // Reset per-session refs. React doesn't unmount the component + // on reinitKey bump — the effect just re-runs — so every ref + // created with `useRef` still holds whatever value it had at + // cleanup. Without this reset, the new session runs against + // stale state from the previous one (dead editor instance, + // pre-delay already "expired", old iframe window, etc.) and + // inbound events end up going nowhere. + documentReadyRef.current = false; + innerWindowRef.current = null; + pendingRemoteChangesRef.current = []; + replayBufferRef.current = []; + replayPreDelayActiveRef.current = true; + pendingApplyVerificationsRef.current = []; + outgoingSilencedRef.current = false; + setEditorInstance(null); + // Step 1: Load OnlyOffice script setState('loading'); await loadOOScript(); @@ -790,6 +796,30 @@ export const OOEditor = ({ item }: OOEditorProps) => { const innerWindow = ooIframe?.contentWindow as any; innerWindowRef.current = innerWindow; + // Install the OO-crash error listener INSIDE the iframe + // too — uncaught errors from sdk-all.js fire on the + // iframe's window, not the top-level one, so our outer + // listener never sees them. + if (innerWindow && !innerWindow.__driveCrashHooked) { + innerWindow.__driveCrashHooked = true; + innerWindow.addEventListener('error', (evt: ErrorEvent) => { + handleOOError(evt); + }); + innerWindow.addEventListener( + 'unhandledrejection', + (evt: PromiseRejectionEvent) => { + const msg = String( + evt.reason?.stack ?? evt.reason ?? '', + ); + handleOOError({ + filename: '', + message: msg, + error: evt.reason, + } as ErrorEvent); + }, + ); + } + // Pre-create OO's clipboard sanitization iframe with a // permissive sandbox. OO's CommonIframe_PasteStart sets // sandbox="allow-same-origin" (no allow-scripts), which @@ -869,64 +899,68 @@ export const OOEditor = ({ item }: OOEditorProps) => { } const innerEditor = innerWindow?.editor || innerWindow?.editorCell; - // Defensive shields for OO-internal crashes we can't fix at - // the source. Each one wraps a hot-path method with a - // try/catch so a bad input doesn't unmount the editor — - // we log a warning and let the next iteration / event - // proceed. Idempotent via a per-method flag. - const shield = ( - proto: any, - method: string, - label: string, - ) => { - if (!proto || typeof proto[method] !== 'function') return; - const flag = `__driveShielded_${method}`; - if (proto[flag]) return; - const orig = proto[method]; - proto[method] = function (...args: unknown[]) { - try { - return orig.apply(this, args); - } catch (e) { - console.warn( - `[OOEditor] swallowed ${label} error`, - e, - ); - } - }; - proto[flag] = true; - }; + // NOTE: we no longer monkey-patch any sdkjs prototype + // method (Update_ForeignCursor, CGraphicObjects.*, + // SpellCheck_CallBack, private_LockByMe, etc.). Those + // wrappers were fragile — each one tied us to specific + // internal paths that break on OO upgrades and leave + // us silently catching errors we didn't anticipate. + // The uniform rule is now: any uncaught error from + // sdkjs triggers the `oo-crashed` overlay via the + // iframe-level `error` listener, and the user reloads + // to pick up the last saved state. - // CDocument.Update_ForeignCursor — Run.GetDocumentPositionFromObject - // missing when remote cursor references a non-ParaRun. - shield( - innerWindow?.CDocument?.prototype, - 'Update_ForeignCursor', - 'Update_ForeignCursor', - ); + // Listen for OO confirming a remote-apply cycle. We + // drain the pending-verification queue here: read the + // CoHistory change counter once and reconcile the + // aggregate expected delta against the aggregate + // observed delta. OO fires this event synchronously + // at the end of its Apply_Changes pipeline, so by the + // time we get it, CoHistory.GetChangeCount() already + // reflects the successfully-applied subset. + if ( + innerEditor && + typeof innerEditor.asc_registerCallback === 'function' && + !innerEditor.__driveApplyVerifierInstalled + ) { + innerEditor.__driveApplyVerifierInstalled = true; + innerEditor.asc_registerCallback( + 'asc_onApplyChanges', + () => { + const queue = pendingApplyVerificationsRef.current; + if (queue.length === 0) return; + const after = readCollabChangeCount(); + if (after === null) { + pendingApplyVerificationsRef.current = []; + return; + } + const firstBefore = queue[0].before; + let totalExpected = 0; + for (const p of queue) totalExpected += p.expected; + const totalActual = after - firstBefore; + pendingApplyVerificationsRef.current = []; + if (totalActual < totalExpected) { + console.error( + '[OOEditor] remote apply dropped changes — ' + + `expected +${totalExpected}, got +${totalActual} ` + + `(before=${firstBefore} after=${after})`, + queue.map(p => p.message), + ); + if (!cancelled) enterCrashState(); + } + }, + ); + } - // CGraphicObjects.removeById — oDrawing.isHdrFtrChild missing. - // CGraphicObjects.updateCursorType — oShape.isForm missing. - const CGO = - innerWindow?.AscFormat?.CGraphicObjects || - innerWindow?.AscCommonWord?.CGraphicObjects; - shield(CGO?.prototype, 'removeById', 'CGraphicObjects.removeById'); - shield( - CGO?.prototype, - 'updateCursorType', - 'CGraphicObjects.updateCursorType', - ); - - // CCollaborativeChanges.Apply_Data — individual change - // class methods (private_UpdateMarksOnSplit etc.) missing. - // Wrapping at Apply_Data lets the per-change loop in - // Apply_OtherChanges keep going past one bad change. - const CCC = - innerWindow?.AscCommon?.CCollaborativeChanges; - shield( - CCC?.prototype, - 'Apply_Data', - 'CCollaborativeChanges.Apply_Data', - ); + // CCollaborativeChanges.Apply_Data is INTENTIONALLY not + // shielded. When a change fails to apply, the document + // state becomes inconsistent — silently swallowing it + // lets OO keep processing subsequent changes on top of + // a corrupt graph, which diverges peers invisibly. We + // let the error propagate so OO's batch loop bails, + // and our iframe-level `error` listener catches the + // uncaught exception and flips the editor to the + // `oo-crashed` state with the reload overlay. if (innerEditor) { let proto = Object.getPrototypeOf(innerEditor); @@ -1320,8 +1354,8 @@ export const OOEditor = ({ item }: OOEditorProps) => { historyPhaseComplete = true; console.log( '[OOEditor] preload phase complete —', - replayBufferRef.current.size, - 'saveChanges in reorder buffer,', + replayBufferRef.current.length, + 'saveChanges buffered,', pendingRemoteChangesRef.current.length, 'ancillary events queued,', Object.keys(historyInitialMedia).length, @@ -1394,10 +1428,33 @@ export const OOEditor = ({ item }: OOEditorProps) => { // sender's counter. When we later emit our own outgoing // `saveChanges`, `wrapOutgoingSaveChanges` will continue // numbering from there — matching OO's internal state. + // + // For every envelope we also push a pending-verification + // entry recording the expected change count delta and + // the CoHistory.GetChangeCount() snapshot at send time. + // OO fires `asc_onApplyChanges` synchronously at the + // end of its Apply_Changes pipeline — our listener + // (installed when the inner editor is ready) drains + // the queue and compares actual vs expected. If OO + // silently dropped a batch, the counter won't advance + // enough and we flip to the crash overlay. const apply = () => { observeIncomingSaveChanges( message as Record, ); + const expectedDelta = Array.isArray( + (message as { changes?: unknown[] }).changes, + ) + ? (message as { changes: unknown[] }).changes.length + : 0; + const before = readCollabChangeCount(); + if (expectedDelta > 0 && before !== null) { + pendingApplyVerificationsRef.current.push({ + expected: expectedDelta, + before, + message, + }); + } withIncomingOTGate(() => sendToEditorGuarded(message as any), ); @@ -1415,8 +1472,16 @@ export const OOEditor = ({ item }: OOEditorProps) => { onHistoryEnd: () => { endPreloadPhase(); }, - onPeerJoin: (userId, userName, peerCanEdit) => { - if (peerCanEdit) editorPeersRef.current.add(userId); + onPeerJoin: (userId, userName, peerCanEdit, joinedAt) => { + if (peerCanEdit) { + const existing = editorPeersRef.current.get(userId); + // Keep the earliest joinedAt for a userId — if a + // second tab of the same user joins later, the + // original one still wins the leader tiebreak. + if (existing === undefined || joinedAt < existing) { + editorPeersRef.current.set(userId, joinedAt); + } + } addRemoteUser(userId, userName, userId); sendToEditor(buildConnectStateMessage() as any); }, @@ -1430,7 +1495,12 @@ export const OOEditor = ({ item }: OOEditorProps) => { editorPeersRef.current.clear(); for (const peer of peers) { if (peer.userId) { - if (peer.canEdit) editorPeersRef.current.add(peer.userId); + if (peer.canEdit) { + const existing = editorPeersRef.current.get(peer.userId); + if (existing === undefined || peer.joinedAt < existing) { + editorPeersRef.current.set(peer.userId, peer.joinedAt); + } + } addRemoteUser(peer.userId, peer.userName, peer.userId); } } @@ -1572,7 +1642,7 @@ export const OOEditor = ({ item }: OOEditorProps) => { // architecture confirms the right path is `sendMessageToOO`. console.log( '[OOEditor:preload] ready to construct editor —', - replayBufferRef.current.size, + replayBufferRef.current.length, 'saveChanges buffered,', pendingRemoteChangesRef.current.length, 'ancillary events queued,', @@ -1593,6 +1663,16 @@ export const OOEditor = ({ item }: OOEditorProps) => { const callbacks = createMockServerCallbacks({ docType, onSaveChangesBroadcast: message => { + // A crashed editor must not leak its corrupt state to + // peers — otherwise one broken tab takes down the whole + // room when its autosave loop emits garbage that crashes + // Apply_OtherChanges on every other client. + if (outgoingSilencedRef.current) { + console.warn( + '[OOEditor] outgoing saveChanges suppressed (crash state)', + ); + return; + } // DIAGNOSTIC: dump every outbound saveChanges so we can // compare what user1 emits vs what user2 receives and tries // to apply in its history replay. @@ -1639,15 +1719,19 @@ export const OOEditor = ({ item }: OOEditorProps) => { relayRef.current?.sendSaveChanges(message); }, onLockRequest: (type, lockData) => { + if (outgoingSilencedRef.current) return; relayRef.current?.sendLock(type, lockData); }, onCursorUpdate: cursor => { + if (outgoingSilencedRef.current) return; relayRef.current?.sendCursor(cursor); }, onMessageBroadcast: messages => { + if (outgoingSilencedRef.current) return; relayRef.current?.sendMessage(messages); }, onMetaBroadcast: messages => { + if (outgoingSilencedRef.current) return; relayRef.current?.sendMeta(messages); }, onSaveLockCheck: () => false, @@ -1661,24 +1745,28 @@ export const OOEditor = ({ item }: OOEditorProps) => { 'abilities:', item.abilities, ); + // Install the leader-election implementation into the ref so + // both initCheckpointing and the beforeunload handler use the + // same rule. The closure captures `canEdit` and `user` from + // scope but reads the live peer map / relay joinedAt each + // call, so it stays correct as peers join/leave. + isSaveLeaderRef.current = () => { + if (!canEdit) return false; + const myId = user!.sub!; + const myJoinedAt = relayRef.current?.joinedAt ?? 0; + for (const [peerId, peerJoinedAt] of editorPeersRef.current) { + if (peerId < myId) return false; + if (peerId === myId && peerJoinedAt < myJoinedAt) return false; + } + return true; + }; if (canEdit) initCheckpointing({ editor, format: x2tExtension, type: x2tType, userId: user.sub!, onUpload: uploadEncrypted, - // Deterministic leader election: lowest userId among connected EDITORS saves. - // Every client computes this independently — no coordination needed. - // Readers never save. If alone (no editor peers), this editor is leader. - isSaveLeader: () => { - if (!canEdit) return false; - const peers = editorPeersRef.current; - if (peers.size === 0) return true; - for (const peerId of peers) { - if (peerId < user!.sub!) return false; - } - return true; - }, + isSaveLeader: () => isSaveLeaderRef.current(), }); // Listen for tab close/refresh to save or warn @@ -1704,6 +1792,7 @@ export const OOEditor = ({ item }: OOEditorProps) => { cancelled = true; window.removeEventListener('beforeunload', handleBeforeUnload); document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('error', handleOOError); stopCheckpointing(); forceSave().catch(console.error); // Clean up locks and relay @@ -1718,7 +1807,12 @@ export const OOEditor = ({ item }: OOEditorProps) => { } catch { // Editor may already be destroyed } + editorRef.current = null; } + // Clear the mock-server's editor reference so any in-flight + // messages between here and the next init() don't land on the + // destroyed instance. + setEditorInstance(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [item.id, reinitKey]); @@ -1797,7 +1891,7 @@ export const OOEditor = ({ item }: OOEditorProps) => { {/* OnlyOffice replaces this div with its own iframe[name="frameEditor"] */}
{/* Loading overlay */} - {state !== 'ready' && ( + {state !== 'ready' && state !== 'oo-crashed' && (
{
)} + {/* OO crash recovery overlay */} + {state === 'oo-crashed' && ( +
+ + sync_problem + +

+ {t( + 'explorer.encrypted.oo_crashed_title', + 'Synchronization error', + )} +

+

+ {t( + 'explorer.encrypted.oo_crashed_body', + 'An error occurred while synchronizing with other peers. ' + + 'Because the document is end-to-end encrypted, we cannot ' + + 'always reconcile changes perfectly. Please reload the ' + + 'editor to fetch the latest saved state.', + )} +

+ +
+ )} {/* Connection status — only show when there's a problem */} {relayFailed && (
; + peers: Array<{ + userId: string; + userName: string; + canEdit: boolean; + joinedAt: number; + }>; historyLength: number; }; @@ -23,6 +28,7 @@ type PeerJoinMessage = { userId: string; userName: string; canEdit: boolean; + joinedAt: number; }; /** A peer left the room */ @@ -69,6 +75,8 @@ type AuthenticatedMessage = { type: 'system:authenticated'; userId: string; userName: string; + /** Relay-assigned connection timestamp, used as leader-election tiebreaker. */ + joinedAt: number; }; /** Server signals the end of the history replay burst */ @@ -131,12 +139,22 @@ export interface RelayCallbacks { media?: Record ) => void; /** Called when a peer joins the room */ - onPeerJoin: (userId: string, userName: string, canEdit: boolean) => void; + onPeerJoin: ( + userId: string, + userName: string, + canEdit: boolean, + joinedAt: number, + ) => void; /** Called when a peer leaves the room */ onPeerLeave: (userId: string) => void; /** Called when room state is received (initial peer list) */ onRoomState: ( - peers: Array<{ userId: string; userName: string; canEdit: boolean }> + peers: Array<{ + userId: string; + userName: string; + canEdit: boolean; + joinedAt: number; + }>, ) => void; /** Called when a lock is acquired/released by a peer */ onLockUpdate: ( @@ -199,6 +217,13 @@ export class EncryptedRelay { * successful local save. */ private sinceTimestampMs: number; + /** + * The `joinedAt` timestamp the relay assigned when it accepted our + * connection. Populated by the `system:authenticated` frame. Used by + * the caller as a leader-election tiebreaker when multiple peers + * share a userId. + */ + public joinedAt: number | null = null; /** * Serializes message handling so async binary decrypts can't be overtaken * by synchronous text frames that arrived after them on the wire. Without @@ -568,7 +593,11 @@ export class EncryptedRelay { } switch (msg.type) { case 'system:authenticated': - // Auth confirmed — nothing to do, connection is already established + // Auth confirmed; record our relay-assigned `joinedAt` so the + // editor can use it as a leader-election tiebreaker. + if (typeof msg.joinedAt === 'number') { + this.joinedAt = msg.joinedAt; + } break; case 'history:end': this.inHistoryPhase = false; @@ -578,7 +607,12 @@ export class EncryptedRelay { this.callbacks.onRoomState(msg.peers); break; case 'peer:join': - this.callbacks.onPeerJoin(msg.userId, msg.userName, msg.canEdit); + this.callbacks.onPeerJoin( + msg.userId, + msg.userName, + msg.canEdit, + msg.joinedAt, + ); break; case 'peer:leave': this.callbacks.onPeerLeave(msg.userId); diff --git a/src/frontend/servers/collaboration/relay.ts b/src/frontend/servers/collaboration/relay.ts index 7c59dc3e..a2b28fe2 100644 --- a/src/frontend/servers/collaboration/relay.ts +++ b/src/frontend/servers/collaboration/relay.ts @@ -192,7 +192,8 @@ function handleConnection( ws: WebSocket, roomId: string, auth: AuthResult, - sinceTimestampMs: number + sinceTimestampMs: number, + joinedAt: number ): void { ws.binaryType = 'arraybuffer'; @@ -202,7 +203,7 @@ function handleConnection( userId: auth.userId, userName: auth.userName, canEdit: auth.canEdit, - joinedAt: Date.now(), + joinedAt, pongReceived: true, pingTimer: null, }; @@ -298,6 +299,7 @@ function handleConnection( userId: meta.userId, userName: meta.userName, canEdit: meta.canEdit, + joinedAt: meta.joinedAt, }); // Send room state to the new joiner @@ -307,6 +309,7 @@ function handleConnection( userId: m.userId, userName: m.userName, canEdit: m.canEdit, + joinedAt: m.joinedAt, })); // Filter history to events strictly newer than the snapshot epoch the @@ -477,20 +480,27 @@ wss.on('connection', async (ws, req) => { return; } + // Compute joinedAt once so the authenticated frame and the PeerMeta + // share exactly the same value — clients use it as a tiebreaker for + // leader election when multiple peers share a userId (same human in + // two tabs). + const joinedAt = Date.now(); + // Confirm authentication to the client ws.send( JSON.stringify({ type: 'system:authenticated', userId: auth.userId, userName: auth.userName, + joinedAt, }) ); console.log( - `[relay] ${auth.userName} (${auth.userId}) joined room ${roomId} (canEdit: ${auth.canEdit})` + `[relay] ${auth.userName} (${auth.userId}) joined room ${roomId} (canEdit: ${auth.canEdit}) joinedAt=${joinedAt}` ); - handleConnection(ws, roomId, auth, sinceTimestampMs); + handleConnection(ws, roomId, auth, sinceTimestampMs, joinedAt); }); httpServer.listen(PORT, () => {