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>
This commit is contained in:
@@ -219,19 +219,26 @@ ot.Client = (function (global) {
|
|||||||
this.acknowlaged = acknowlaged;
|
this.acknowlaged = acknowlaged;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.revision = revision;
|
this.revision = revision;
|
||||||
|
this.pendingOps = [];
|
||||||
}
|
}
|
||||||
Client.Stale = Stale;
|
Client.Stale = Stale;
|
||||||
|
|
||||||
Stale.prototype.applyClient = function (client, operation) {
|
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) {
|
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) {
|
Stale.prototype.applyOperations = function (client, head, operations) {
|
||||||
var transform = this.acknowlaged.constructor.transform;
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
// Apply the requested missing operations (concurrent with acknowlaged)
|
||||||
for (var i = 0; i < operations.length; i++) {
|
for (var i = 0; i < operations.length; i++) {
|
||||||
var op = ot.TextOperation.fromJSON(operations[i]);
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
var pair = transform(this.acknowlaged, op);
|
var pair = transform(this.acknowlaged, op);
|
||||||
@@ -239,6 +246,14 @@ ot.Client = (function (global) {
|
|||||||
this.acknowlaged = pair[0];
|
this.acknowlaged = pair[0];
|
||||||
}
|
}
|
||||||
client.revision = this.revision;
|
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_;
|
return synchronized_;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,6 +276,7 @@ ot.Client = (function (global) {
|
|||||||
this.buffer = buffer;
|
this.buffer = buffer;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.revision = revision;
|
this.revision = revision;
|
||||||
|
this.pendingOps = [];
|
||||||
}
|
}
|
||||||
Client.StaleWithBuffer = StaleWithBuffer;
|
Client.StaleWithBuffer = StaleWithBuffer;
|
||||||
|
|
||||||
@@ -270,11 +286,15 @@ ot.Client = (function (global) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
StaleWithBuffer.prototype.applyServer = function (client, revision, operation) {
|
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) {
|
StaleWithBuffer.prototype.applyOperations = function (client, head, operations) {
|
||||||
var transform = this.acknowlaged.constructor.transform;
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
// Apply the requested missing operations (concurrent with acknowlaged and buffer)
|
||||||
for (var i = 0; i < operations.length; i++) {
|
for (var i = 0; i < operations.length; i++) {
|
||||||
var op = ot.TextOperation.fromJSON(operations[i]);
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
var pair1 = transform(this.acknowlaged, op);
|
var pair1 = transform(this.acknowlaged, op);
|
||||||
@@ -284,6 +304,16 @@ ot.Client = (function (global) {
|
|||||||
this.buffer = pair2[0];
|
this.buffer = pair2[0];
|
||||||
}
|
}
|
||||||
client.revision = this.revision;
|
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);
|
client.sendOperation(client.revision, this.buffer);
|
||||||
return new AwaitingConfirm(this.buffer);
|
return new AwaitingConfirm(this.buffer);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,14 +51,23 @@ EditorSocketIOServer.prototype.addClient = function (socket) {
|
|||||||
self.onOperation(socket, revision, operation, selection);
|
self.onOperation(socket, revision, operation, selection);
|
||||||
if (typeof self.operationCallback === 'function')
|
if (typeof self.operationCallback === 'function')
|
||||||
self.operationCallback(socket, operation);
|
self.operationCallback(socket, operation);
|
||||||
|
socket._opRetries = 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logger.error("Operation error from client " + socket.id + ": " + err.message);
|
||||||
|
if (!socket._opRetries) socket._opRetries = 0;
|
||||||
|
socket._opRetries++;
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
var docOut = {
|
var docOut = {
|
||||||
str: self.document,
|
str: self.document,
|
||||||
revision: self.operations.length,
|
revision: self.operations.length,
|
||||||
clients: self.users,
|
clients: self.users
|
||||||
force: true
|
|
||||||
};
|
};
|
||||||
|
// 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);
|
socket.emit('doc', docOut);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## <i class="fa fa-tag"></i> 1.x.x <i class="fa fa-calendar-o"></i> 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
|
||||||
|
|
||||||
## <i class="fa fa-tag"></i> 1.10.7 <i class="fa fa-calendar-o"></i> 2026-02-24
|
## <i class="fa fa-tag"></i> 1.10.7 <i class="fa fa-calendar-o"></i> 2026-02-24
|
||||||
|
|
||||||
### Bugfixes
|
### Bugfixes
|
||||||
|
|||||||
36
public/vendor/ot/client.js
vendored
36
public/vendor/ot/client.js
vendored
@@ -219,19 +219,26 @@ ot.Client = (function (global) {
|
|||||||
this.acknowlaged = acknowlaged;
|
this.acknowlaged = acknowlaged;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.revision = revision;
|
this.revision = revision;
|
||||||
|
this.pendingOps = [];
|
||||||
}
|
}
|
||||||
Client.Stale = Stale;
|
Client.Stale = Stale;
|
||||||
|
|
||||||
Stale.prototype.applyClient = function (client, operation) {
|
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) {
|
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) {
|
Stale.prototype.applyOperations = function (client, head, operations) {
|
||||||
var transform = this.acknowlaged.constructor.transform;
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
// Apply the requested missing operations (concurrent with acknowlaged)
|
||||||
for (var i = 0; i < operations.length; i++) {
|
for (var i = 0; i < operations.length; i++) {
|
||||||
var op = ot.TextOperation.fromJSON(operations[i]);
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
var pair = transform(this.acknowlaged, op);
|
var pair = transform(this.acknowlaged, op);
|
||||||
@@ -239,6 +246,14 @@ ot.Client = (function (global) {
|
|||||||
this.acknowlaged = pair[0];
|
this.acknowlaged = pair[0];
|
||||||
}
|
}
|
||||||
client.revision = this.revision;
|
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_;
|
return synchronized_;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,6 +276,7 @@ ot.Client = (function (global) {
|
|||||||
this.buffer = buffer;
|
this.buffer = buffer;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.revision = revision;
|
this.revision = revision;
|
||||||
|
this.pendingOps = [];
|
||||||
}
|
}
|
||||||
Client.StaleWithBuffer = StaleWithBuffer;
|
Client.StaleWithBuffer = StaleWithBuffer;
|
||||||
|
|
||||||
@@ -270,11 +286,15 @@ ot.Client = (function (global) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
StaleWithBuffer.prototype.applyServer = function (client, revision, operation) {
|
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) {
|
StaleWithBuffer.prototype.applyOperations = function (client, head, operations) {
|
||||||
var transform = this.acknowlaged.constructor.transform;
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
// Apply the requested missing operations (concurrent with acknowlaged and buffer)
|
||||||
for (var i = 0; i < operations.length; i++) {
|
for (var i = 0; i < operations.length; i++) {
|
||||||
var op = ot.TextOperation.fromJSON(operations[i]);
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
var pair1 = transform(this.acknowlaged, op);
|
var pair1 = transform(this.acknowlaged, op);
|
||||||
@@ -284,6 +304,16 @@ ot.Client = (function (global) {
|
|||||||
this.buffer = pair2[0];
|
this.buffer = pair2[0];
|
||||||
}
|
}
|
||||||
client.revision = this.revision;
|
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);
|
client.sendOperation(client.revision, this.buffer);
|
||||||
return new AwaitingConfirm(this.buffer);
|
return new AwaitingConfirm(this.buffer);
|
||||||
};
|
};
|
||||||
|
|||||||
9
public/vendor/ot/hex2rgb.js
vendored
9
public/vendor/ot/hex2rgb.js
vendored
@@ -1,12 +1,17 @@
|
|||||||
function hex2rgb (hex) {
|
function hex2rgb (hex) {
|
||||||
|
if (!hex || typeof hex !== 'string') return { red: 0, green: 0, blue: 0 }
|
||||||
if (hex[0] == '#') hex = hex.substr(1)
|
if (hex[0] == '#') hex = hex.substr(1)
|
||||||
if (hex.length == 3) {
|
if (hex.length == 3) {
|
||||||
var temp = hex
|
var temp = hex
|
||||||
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]
|
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 {
|
return {
|
||||||
red: parseInt(triplets[0], 16),
|
red: parseInt(triplets[0], 16),
|
||||||
green: parseInt(triplets[1], 16),
|
green: parseInt(triplets[1], 16),
|
||||||
|
|||||||
Reference in New Issue
Block a user