diff options
| author | haoyuren <13851610112@163.com> | 2026-03-18 08:06:32 +0000 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-03-18 08:06:32 +0000 |
| commit | 9b5256718c2117511f0253a656bb8cff7410b92a (patch) | |
| tree | 8ba0fd257f771538874f37b87dcaeb5471185ca5 /src/main/fileSyncBridge.ts | |
| parent | 69a09baf71798966724d942b93303211516e34c7 (diff) | |
Fix OT sync corruption: match Overleaf ShareJS ack/echo handling
The server broadcasts otUpdateApplied (with ops) to ALL clients including
the sender. Our bridge was treating its own echoed ops as remote ops and
re-applying them, causing text duplication (e.g. "simulatorimulator").
Rewrite OT handling to match Overleaf's ShareJS _onMessage pattern:
- ACK = no ops OR meta.source matches our publicId (own echo)
- REMOTE = ops from a different source
- ACK path calls onAck() without re-applying ops
- OtClient silently drops duplicate acks in synchronized state
- OtClient drops stale remote ops (version < current)
- Remove pendingEchos counter in favor of meta.source detection
Also: refresh MCP comment contexts on new-comment/delete-thread events,
add Overleaf reference repo to .gitignore.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src/main/fileSyncBridge.ts')
| -rw-r--r-- | src/main/fileSyncBridge.ts | 79 |
1 files changed, 60 insertions, 19 deletions
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts index c9397f0..707d3d8 100644 --- a/src/main/fileSyncBridge.ts +++ b/src/main/fileSyncBridge.ts @@ -268,30 +268,60 @@ export class FileSyncBridge { } // ── OT update handler ───────────────────────────────────── + // + // Modeled after Overleaf's ShareJS _onMessage (vendor/libs/sharejs.js): + // + // ACK = msg.op === undefined (explicit ack, no ops) + // | msg.meta.source === ourId (echo of our own ops) + // + // REMOTE = msg.op && msg.meta.source !== ourId + // + // The ACK path does NOT re-apply ops (they were applied optimistically). + // The REMOTE path transforms against inflight/buffered ops before applying. + // Stale messages (version < ours) are silently dropped by OtClient. private handleOtUpdate(args: unknown[]): void { - const update = args[0] as { doc?: string; op?: OtOp[]; v?: number } | undefined + const update = args[0] as { doc?: string; op?: OtOp[]; v?: number; meta?: { source?: string } } | undefined if (!update?.doc) return const docId = update.doc - - // For non-editor docs, process remote ops through bridge's OtClient - if (!this.editorDocs.has(docId) && update.op && update.v !== undefined) { - const otClient = this.otClients.get(docId) - if (otClient) { - otClient.onRemoteOps(update.op, update.v) - } + const relPath = this.docPathMap[docId] || docId + + // Skip editor docs — renderer handles their OT + if (this.editorDocs.has(docId)) return + + const otClient = this.otClients.get(docId) + if (!otClient) return + + const isOwnSource = this.isOwnSource(update.meta?.source) + + bridgeLog(`[FileSyncBridge] handleOtUpdate: ${relPath} v=${update.v} hasOp=${!!update.op} own=${isOwnSource} state=${otClient.stateName} ver=${otClient.version}`) + + if (!update.op || isOwnSource) { + // ACK — either: + // 1. No ops (explicit ack from server to sender) + // 2. Echoed ops from our own source (server broadcast to all clients) + // In both cases: acknowledge the inflight op. Do NOT re-apply ops. + // OtClient.onAck() silently drops duplicate acks (synchronized state). + bridgeLog(`[FileSyncBridge] handleOtUpdate: ACK for ${relPath} (${!update.op ? 'no-op' : 'own-echo'}), state=${otClient.stateName}`) + otClient.onAck() + bridgeLog(`[FileSyncBridge] handleOtUpdate: ACK done → state=${otClient.stateName} ver=${otClient.version}`) + } else if (update.op && update.v !== undefined) { + // REMOTE — genuine ops from another client + bridgeLog(`[FileSyncBridge] handleOtUpdate: REMOTE ops for ${relPath} from ${update.meta?.source || 'unknown'}, state=${otClient.stateName}`) + otClient.onRemoteOps(update.op, update.v) } + } - // Handle ack — process even for editor docs so the bridge's OtClient - // can finish pending ops (e.g. ops sent just before addEditorDoc was called). - // Without this, the OtClient stays stuck in awaitingConfirm and the ops - // appear lost until removeEditorDoc re-discovers the discrepancy. - if (!update.op) { - const otClient = this.otClients.get(docId) - if (otClient) { - otClient.onAck() - } + /** Check if an otUpdateApplied event originated from our own socket */ + private isOwnSource(metaSource?: string): boolean { + // Primary: match meta.source against our publicId (same as Overleaf's inflightSubmittedIds check) + if (metaSource && this.socket.publicId) { + return metaSource === this.socket.publicId } + // If meta.source is absent, we can't determine origin. + // The open-source server sends ack without meta to the sender (no ops, no meta.source). + // If we get ops WITHOUT meta.source, treat conservatively as remote. + return false } // ── OT error handler ──────────────────────────────────────── @@ -311,7 +341,7 @@ export class FileSyncBridge { const relPath = this.docPathMap[docId] if (!relPath) return - bridgeLog(`[FileSyncBridge] otUpdateError for ${relPath}: ${error.message || 'unknown'}`) + bridgeLog(`[FileSyncBridge] OT_ERROR for ${relPath}: ${error.message || JSON.stringify(error)}`) // Re-join the doc to get fresh version and content, then re-apply disk content if different this.socket.joinDoc(docId).then(async (result) => { @@ -796,7 +826,18 @@ export class FileSyncBridge { const relPath = this.docPathMap[docId] const content = relPath ? this.lastKnownContent.get(relPath) ?? '' : '' const hash = createHash('sha1').update(content).digest('hex') - this.socket.applyOtUpdate(docId, ops, version, hash) + bridgeLog(`[FileSyncBridge] sendOps: ${relPath} v${version} ${ops.length} ops, hash=${hash.slice(0, 8)}`) + this.socket.applyOtUpdate(docId, ops, version, hash).then(() => { + bridgeLog(`[FileSyncBridge] sendOps: server accepted ${relPath}`) + // Do NOT call onAck() here. + // ACK comes via otUpdateApplied events processed in handleOtUpdate: + // - Echo (with ops, meta.source === ours) → treated as ACK + // - Explicit ack (without ops) → treated as ACK + // OtClient.onAck() silently deduplicates if both arrive. + }).catch((e) => { + bridgeLog(`[FileSyncBridge] sendOps: REJECTED for ${relPath}: ${e?.message || e}`) + this.handleOtError([{ doc: docId, message: e?.message || 'applyOtUpdate rejected' }]) + }) } // ── Apply remote ops (for non-editor docs) ────────────────── |
