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:
bxff
2026-03-16 15:16:47 +08:00
committed by Philip Molares
parent f51f8dcc19
commit 65bf11554a
5 changed files with 90 additions and 10 deletions

View File

@@ -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);
};