From 65bf11554a4993fb075c0217e095043fc7fa005f Mon Sep 17 00:00:00 2001
From: bxff <51504045+bxff@users.noreply.github.com>
Date: Mon, 16 Mar 2026 15:16:47 +0800
Subject: [PATCH] fix: buffer operations in Stale/StaleWithBuffer instead of
throwing (#6342)
When 5+ users edit concurrently, revision gaps trigger the Stale state
which throws 'Ignored server-side change.' on incoming operations. This
causes document divergence, silent data corruption, and eventually
force-resets that discard local edits.
Changes:
- Stale/StaleWithBuffer.applyServer: buffer incoming operations instead
of throwing, apply them sequentially after missing ops arrive
- Stale.applyClient: carry pendingOps to StaleWithBuffer on transition
- editor-socketio-server: retry-based force-reset (3 failures) instead
of immediate nuclear reset on any OT error
- hex2rgb: defensive null checks for old non-hex color records
Signed-off-by: bxff <51504045+bxff@users.noreply.github.com>
---
lib/ot/client.js | 36 +++++++++++++++++++++++++++++---
lib/ot/editor-socketio-server.js | 13 ++++++++++--
public/docs/release-notes.md | 6 ++++++
public/vendor/ot/client.js | 36 +++++++++++++++++++++++++++++---
public/vendor/ot/hex2rgb.js | 9 ++++++--
5 files changed, 90 insertions(+), 10 deletions(-)
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),