diff --git a/lib/ot/client.js b/lib/ot/client.js index 7ee19fdc..c4384e88 100755 --- a/lib/ot/client.js +++ b/lib/ot/client.js @@ -219,19 +219,26 @@ ot.Client = (function (global) { this.acknowlaged = acknowlaged; this.client = client; this.revision = revision; + this.pendingOps = []; } Client.Stale = Stale; Stale.prototype.applyClient = function (client, operation) { - return new StaleWithBuffer(this.acknowlaged, operation, client, this.revision); + var swb = new StaleWithBuffer(this.acknowlaged, operation, client, this.revision); + swb.pendingOps = this.pendingOps; + return swb; }; Stale.prototype.applyServer = function (client, revision, operation) { - throw new Error("Ignored server-side change."); + // Buffer incoming operations while waiting for the missing operations + // response. These will be applied sequentially after the missing ops. + this.pendingOps.push({ revision: revision, operation: operation }); + return this; }; Stale.prototype.applyOperations = function (client, head, operations) { var transform = this.acknowlaged.constructor.transform; + // Apply the requested missing operations (concurrent with acknowlaged) for (var i = 0; i < operations.length; i++) { var op = ot.TextOperation.fromJSON(operations[i]); var pair = transform(this.acknowlaged, op); @@ -239,6 +246,14 @@ ot.Client = (function (global) { this.acknowlaged = pair[0]; } client.revision = this.revision; + // Apply any operations that arrived while we were waiting for the + // missing operations. These are sequential (not concurrent), so they + // can be applied directly like Synchronized.applyServer would. + for (var j = 0; j < this.pendingOps.length; j++) { + var pending = this.pendingOps[j]; + client.revision = pending.revision; + client.applyOperation(pending.operation); + } return synchronized_; }; @@ -261,6 +276,7 @@ ot.Client = (function (global) { this.buffer = buffer; this.client = client; this.revision = revision; + this.pendingOps = []; } Client.StaleWithBuffer = StaleWithBuffer; @@ -270,11 +286,15 @@ ot.Client = (function (global) { }; StaleWithBuffer.prototype.applyServer = function (client, revision, operation) { - throw new Error("Ignored server-side change."); + // Buffer incoming operations while waiting for the missing operations + // response. These will be applied sequentially after the missing ops. + this.pendingOps.push({ revision: revision, operation: operation }); + return this; }; StaleWithBuffer.prototype.applyOperations = function (client, head, operations) { var transform = this.acknowlaged.constructor.transform; + // Apply the requested missing operations (concurrent with acknowlaged and buffer) for (var i = 0; i < operations.length; i++) { var op = ot.TextOperation.fromJSON(operations[i]); var pair1 = transform(this.acknowlaged, op); @@ -284,6 +304,16 @@ ot.Client = (function (global) { this.buffer = pair2[0]; } client.revision = this.revision; + // Apply any operations that arrived while we were waiting for the + // missing operations. These are sequential (not concurrent), so we + // transform the buffer against them and apply them to the document. + for (var j = 0; j < this.pendingOps.length; j++) { + var pending = this.pendingOps[j]; + var pair3 = transform(this.buffer, pending.operation); + client.applyOperation(pair3[1]); + this.buffer = pair3[0]; + client.revision = pending.revision; + } client.sendOperation(client.revision, this.buffer); return new AwaitingConfirm(this.buffer); }; diff --git a/lib/ot/editor-socketio-server.js b/lib/ot/editor-socketio-server.js index 5014ac8d..cef29db7 100755 --- a/lib/ot/editor-socketio-server.js +++ b/lib/ot/editor-socketio-server.js @@ -51,14 +51,23 @@ EditorSocketIOServer.prototype.addClient = function (socket) { self.onOperation(socket, revision, operation, selection); if (typeof self.operationCallback === 'function') self.operationCallback(socket, operation); + socket._opRetries = 0; } catch (err) { + logger.error("Operation error from client " + socket.id + ": " + err.message); + if (!socket._opRetries) socket._opRetries = 0; + socket._opRetries++; setTimeout(function() { var docOut = { str: self.document, revision: self.operations.length, - clients: self.users, - force: true + clients: self.users }; + // Only force-reset after 3 consecutive failures as a last resort. + // Without force:true, the client will use its Stale recovery to resync. + if (socket._opRetries >= 3) { + docOut.force = true; + socket._opRetries = 0; + } socket.emit('doc', docOut); }, 100); } diff --git a/public/docs/release-notes.md b/public/docs/release-notes.md index f7f4ddc6..797d5cb8 100644 --- a/public/docs/release-notes.md +++ b/public/docs/release-notes.md @@ -1,5 +1,11 @@ # Release Notes +## 1.x.x UNRELEASED + +### Bugfixes +- Fix data loss when 5+ users edit a document concurrently, caused by the OT client discarding operations during revision gap recovery ([#6342](https://github.com/hedgedoc/hedgedoc/issues/6342)) +- Add defensive null checks to `hex2rgb` to prevent crashes from non-hex color values + ## 1.10.7 2026-02-24 ### Bugfixes diff --git a/public/vendor/ot/client.js b/public/vendor/ot/client.js index 7ee19fdc..c4384e88 100644 --- a/public/vendor/ot/client.js +++ b/public/vendor/ot/client.js @@ -219,19 +219,26 @@ ot.Client = (function (global) { this.acknowlaged = acknowlaged; this.client = client; this.revision = revision; + this.pendingOps = []; } Client.Stale = Stale; Stale.prototype.applyClient = function (client, operation) { - return new StaleWithBuffer(this.acknowlaged, operation, client, this.revision); + var swb = new StaleWithBuffer(this.acknowlaged, operation, client, this.revision); + swb.pendingOps = this.pendingOps; + return swb; }; Stale.prototype.applyServer = function (client, revision, operation) { - throw new Error("Ignored server-side change."); + // Buffer incoming operations while waiting for the missing operations + // response. These will be applied sequentially after the missing ops. + this.pendingOps.push({ revision: revision, operation: operation }); + return this; }; Stale.prototype.applyOperations = function (client, head, operations) { var transform = this.acknowlaged.constructor.transform; + // Apply the requested missing operations (concurrent with acknowlaged) for (var i = 0; i < operations.length; i++) { var op = ot.TextOperation.fromJSON(operations[i]); var pair = transform(this.acknowlaged, op); @@ -239,6 +246,14 @@ ot.Client = (function (global) { this.acknowlaged = pair[0]; } client.revision = this.revision; + // Apply any operations that arrived while we were waiting for the + // missing operations. These are sequential (not concurrent), so they + // can be applied directly like Synchronized.applyServer would. + for (var j = 0; j < this.pendingOps.length; j++) { + var pending = this.pendingOps[j]; + client.revision = pending.revision; + client.applyOperation(pending.operation); + } return synchronized_; }; @@ -261,6 +276,7 @@ ot.Client = (function (global) { this.buffer = buffer; this.client = client; this.revision = revision; + this.pendingOps = []; } Client.StaleWithBuffer = StaleWithBuffer; @@ -270,11 +286,15 @@ ot.Client = (function (global) { }; StaleWithBuffer.prototype.applyServer = function (client, revision, operation) { - throw new Error("Ignored server-side change."); + // Buffer incoming operations while waiting for the missing operations + // response. These will be applied sequentially after the missing ops. + this.pendingOps.push({ revision: revision, operation: operation }); + return this; }; StaleWithBuffer.prototype.applyOperations = function (client, head, operations) { var transform = this.acknowlaged.constructor.transform; + // Apply the requested missing operations (concurrent with acknowlaged and buffer) for (var i = 0; i < operations.length; i++) { var op = ot.TextOperation.fromJSON(operations[i]); var pair1 = transform(this.acknowlaged, op); @@ -284,6 +304,16 @@ ot.Client = (function (global) { this.buffer = pair2[0]; } client.revision = this.revision; + // Apply any operations that arrived while we were waiting for the + // missing operations. These are sequential (not concurrent), so we + // transform the buffer against them and apply them to the document. + for (var j = 0; j < this.pendingOps.length; j++) { + var pending = this.pendingOps[j]; + var pair3 = transform(this.buffer, pending.operation); + client.applyOperation(pair3[1]); + this.buffer = pair3[0]; + client.revision = pending.revision; + } client.sendOperation(client.revision, this.buffer); return new AwaitingConfirm(this.buffer); }; diff --git a/public/vendor/ot/hex2rgb.js b/public/vendor/ot/hex2rgb.js index 176a0f2f..7e1de864 100644 --- a/public/vendor/ot/hex2rgb.js +++ b/public/vendor/ot/hex2rgb.js @@ -1,12 +1,17 @@ function hex2rgb (hex) { + if (!hex || typeof hex !== 'string') return { red: 0, green: 0, blue: 0 } if (hex[0] == '#') hex = hex.substr(1) if (hex.length == 3) { var temp = hex hex = '' - temp = /^([a-f0-9])([a-f0-9])([a-f0-9])$/i.exec(temp).slice(1) + var match = /^([a-f0-9])([a-f0-9])([a-f0-9])$/i.exec(temp) + if (!match) return { red: 0, green: 0, blue: 0 } + temp = match.slice(1) for (var i = 0; i < 3; i++) hex += temp[i] + temp[i] } - var triplets = /^([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.exec(hex).slice(1) + var result = /^([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.exec(hex) + if (!result) return { red: 0, green: 0, blue: 0 } + var triplets = result.slice(1) return { red: parseInt(triplets[0], 16), green: parseInt(triplets[1], 16),