Skip to content

Commit 27b784b

Browse files
SamTV12345claude
authored andcommitted
fix(collab): serialize fan-out per socket with a flag, not by pre-claiming rev
The race this PR identified is real. With the guard removed, three concurrent updatePadClients() runs deliver [1,1,2,1,2,3,2,3,3] to one client — every revision three times (new spec, padFanOutSerialization.ts). But claiming the range by writing `sessioninfo.rev = headRev` before the awaits breaks a guarantee that rev carries elsewhere. handleUserChanges asserts `thisSession.rev === r` right before emitting ACCEPT_COMMIT, with the comment "make sure we have already sent any previous ACCEPT_COMMIT and NEW_CHANGES messages". Pre-claiming satisfies that assert while the NEW_CHANGES for those revisions are still queued behind `await pad.getRevision()`, so ACCEPT_COMMIT can overtake them — the exact out-of-order delivery the assert exists to prevent. The rollback path has the same problem in reverse: a mid-batch emit failure rewinds to startRev and re-sends revisions the client already applied, where the per-revision commit only retried the one that failed. So: keep the loop as it was, add `sessioninfo.fanOutInFlight`. At most one fan-out per socket, rev keeps meaning "last revision actually sent", retry semantics unchanged, and a skipped run loses nothing because the holder re-reads getHeadRevisionNumber() each iteration. Change B (NEW_CHANGES_BATCH) is dropped. It never fired in the dive by the PR's own account, it changes the wire format behind a flag that needs client coordination, and it does not belong in a correctness fix. Worth its own PR if the packet-count argument is ever measured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7abdb01 commit 27b784b

10 files changed

Lines changed: 167 additions & 219 deletions

File tree

‎settings.json.template‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -821,18 +821,6 @@
821821
*/
822822
"loadTest": false,
823823

824-
/*
825-
* Pack multiple NEW_CHANGES revisions into a single NEW_CHANGES_BATCH emit
826-
* per recipient when a fan-out has more than one revision to catch up
827-
* (#7756 lever 3b). Reduces engine.io packet count under high pad
828-
* concurrency, especially on the WebSocket transport.
829-
*
830-
* WARNING: enabling this requires all connected clients to understand
831-
* NEW_CHANGES_BATCH. Old clients will silently fail to apply batched
832-
* revisions. Coordinate the rollout.
833-
*/
834-
"newChangesBatch": false,
835-
836824
/**
837825
* Disable dump of objects preventing a clean exit
838826
*/

‎src/node/handler/NewChangesPacker.ts‎

Lines changed: 0 additions & 41 deletions
This file was deleted.

‎src/node/handler/PadMessageHandler.ts‎

Lines changed: 47 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ const hooks = require('../../static/js/pluginfw/hooks');
4949
const stats = require('../stats')
5050
const assert = require('assert').strict;
5151
import {recordChangesetApply, recordSocketEmit} from '../prom-instruments';
52-
import {buildNewChangesEmits, type NewChangesItem} from './NewChangesPacker';
5352
import {RateLimiterMemory} from 'rate-limiter-flexible';
5453
import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest";
5554
import {APool, AText, PadAuthor, PadType} from "../types/PadType";
@@ -1053,76 +1052,69 @@ exports.updatePadClients = async (pad: PadType) => {
10531052
// but benefit of reusing cached revision object is HUGE
10541053
const revCache:MapArrayType<any> = {};
10551054

1056-
// When `settings.newChangesBatch` is true and a recipient is more than one
1057-
// revision behind, pack the queued revisions into a single NEW_CHANGES_BATCH
1058-
// emit per recipient. The engine.io WebSocket transport sends one frame per
1059-
// packet (the polling transport already batches at the HTTP-response layer),
1060-
// so reducing the packet count translates directly into fewer system calls
1061-
// on the server and fewer onmessage callbacks on the client.
1062-
const batchEnabled = settings.newChangesBatch === true;
1063-
10641055
await Promise.all(roomSockets.map(async (socket) => {
10651056
const sessioninfo = sessioninfos[socket.id];
10661057
// The user might have disconnected since _getRoomSockets() was called.
10671058
if (sessioninfo == null) return;
10681059

1069-
// Snapshot the local state so a concurrent updatePadClients() can't make
1070-
// us double-emit. We hold the "I'm responsible for revs (startRev,
1071-
// headRev]" claim by reading sessioninfo.rev once and overwriting it
1072-
// before any await. A second invocation arriving mid-loop will see the
1073-
// bumped rev and skip those revisions; if our emit fails the catch
1074-
// below rolls sessioninfo.rev back so they aren't lost.
1075-
const startRev = sessioninfo.rev;
1076-
const headRev = pad.getHeadRevisionNumber();
1077-
if (startRev >= headRev) return;
1078-
const startTime = sessioninfo.time;
1079-
// Claim the range immediately so concurrent runs skip it.
1080-
sessioninfo.rev = headRev;
1081-
1082-
// Collect all queued revisions for this socket.
1083-
const pending: Array<{
1084-
newRev: number;
1085-
changeset: string;
1086-
apool: unknown;
1087-
author: string;
1088-
currentTime: number;
1089-
timeDelta: number;
1090-
}> = [];
1091-
1060+
// One fan-out per socket at a time. Without this, two concurrent
1061+
// updatePadClients() runs both read `sessioninfo.rev`, both await
1062+
// pad.getRevision(), and both emit the same revision to the same client
1063+
// (#7756 lever 3): the loop's read-await-write is not atomic.
1064+
//
1065+
// The claim is a separate flag on purpose. `sessioninfo.rev` has to keep
1066+
// meaning "last revision actually put on the wire" — handleUserChanges
1067+
// asserts `thisSession.rev === r` before emitting ACCEPT_COMMIT precisely to
1068+
// guarantee the client sees NEW_CHANGES and ACCEPT_COMMIT in order. Bumping
1069+
// rev up front to claim a range would satisfy that assert while the
1070+
// NEW_CHANGES messages are still queued behind an await, which is the
1071+
// out-of-order delivery the assert exists to prevent.
1072+
//
1073+
// A skipped run loses nothing: the run that holds the flag re-reads
1074+
// getHeadRevisionNumber() every iteration, so it also ships whatever arrived
1075+
// in the meantime.
1076+
if (sessioninfo.fanOutInFlight) return;
1077+
sessioninfo.fanOutInFlight = true;
10921078
try {
1093-
let previousTime = startTime;
1094-
for (let r = startRev + 1; r <= headRev; r++) {
1079+
while (sessioninfo.rev < pad.getHeadRevisionNumber()) {
1080+
const r = sessioninfo.rev + 1;
10951081
let revision = revCache[r];
10961082
if (!revision) {
10971083
revision = await pad.getRevision(r);
10981084
revCache[r] = revision;
10991085
}
1086+
11001087
const author = revision.meta.author;
11011088
const revChangeset = revision.changeset;
11021089
const currentTime = revision.meta.timestamp;
1103-
const forWire = prepareForWire(revChangeset, pad.pool);
1104-
pending.push({
1105-
newRev: r,
1106-
changeset: forWire.translated,
1107-
apool: forWire.pool,
1108-
author,
1109-
currentTime,
1110-
timeDelta: currentTime - previousTime,
1111-
});
1112-
previousTime = currentTime;
1113-
}
11141090

1115-
for (const emit of buildNewChangesEmits(pending, batchEnabled)) {
1116-
socket.emit('message', emit);
1117-
recordSocketEmit(emit.data.type);
1091+
const forWire = prepareForWire(revChangeset, pad.pool);
1092+
const msg = {
1093+
type: 'COLLABROOM',
1094+
data: {
1095+
type: 'NEW_CHANGES',
1096+
newRev: r,
1097+
changeset: forWire.translated,
1098+
apool: forWire.pool,
1099+
author,
1100+
currentTime,
1101+
timeDelta: currentTime - sessioninfo.time,
1102+
},
1103+
};
1104+
try {
1105+
socket.emit('message', msg);
1106+
recordSocketEmit('NEW_CHANGES');
1107+
} catch (err:any) {
1108+
messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`);
1109+
return;
1110+
}
1111+
sessioninfo.time = currentTime;
1112+
sessioninfo.rev = r;
11181113
}
1119-
// Only after the wire send succeeds do we commit the new time.
1120-
sessioninfo.time = previousTime;
1121-
} catch (err: any) {
1122-
// Roll back the claim so the next updatePadClients retries these revs.
1123-
// Only set rev back if no one else has advanced past us in the meantime.
1124-
if (sessioninfo.rev === headRev) sessioninfo.rev = startRev;
1125-
messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`);
1114+
} finally {
1115+
// Cleared even on the `return` inside the emit-failure path above, so a
1116+
// failed send doesn't wedge the socket's fan-out for good.
1117+
sessioninfo.fanOutInFlight = false;
11261118
}
11271119
}));
11281120
};

‎src/node/prom-instruments.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ export const padUsersGauge = new client.Gauge({
4040
// state.
4141
const KNOWN_TYPES = new Set([
4242
'NEW_CHANGES',
43-
'NEW_CHANGES_BATCH',
4443
'ACCEPT_COMMIT',
4544
'CHAT_MESSAGE',
4645
'CLIENT_VARS',

‎src/node/utils/Settings.ts‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,6 @@ export type SettingsType = {
292292
automaticReconnectionTimeout: number,
293293
loadTest: boolean,
294294
scalingDiveMetrics: boolean,
295-
newChangesBatch: boolean,
296295
dumpOnUncleanExit: boolean,
297296
indentationOnNewLine: boolean,
298297
logconfig: any | null,
@@ -732,21 +731,6 @@ const settings: SettingsType = {
732731
* production deployments aren't paying for instrumentation they don't use.
733732
*/
734733
scalingDiveMetrics: false,
735-
/**
736-
* Pack multiple NEW_CHANGES revisions into a single NEW_CHANGES_BATCH emit
737-
* per recipient when a fan-out has more than one revision to catch up
738-
* (#7756 lever 3b). Reduces engine.io packet count under high pad
739-
* concurrency, especially on the WebSocket transport, which sends one
740-
* frame per packet (the polling transport already batches naturally).
741-
*
742-
* Requires clients to recognise the NEW_CHANGES_BATCH message type. New
743-
* clients are forward-compatible (they handle both NEW_CHANGES and
744-
* NEW_CHANGES_BATCH). Old clients connecting to a server with this enabled
745-
* would fail to apply batched revisions, so coordinate the rollout.
746-
*
747-
* 0/false (default) preserves legacy per-revision emit behaviour.
748-
*/
749-
newChangesBatch: false,
750734
/**
751735
* Disable dump of objects preventing a clean exit
752736
*/

‎src/static/js/broadcast.ts‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -493,23 +493,17 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
493493
if (obj.type === 'COLLABROOM') {
494494
obj = obj.data;
495495

496-
if (obj.type === 'NEW_CHANGES' || obj.type === 'NEW_CHANGES_BATCH') {
497-
// NEW_CHANGES_BATCH (#7756 lever 3b) carries an array of revisions
498-
// in one emit. Each revision has the same shape as the legacy
499-
// single-rev message; apply in order.
500-
const changes = obj.type === 'NEW_CHANGES_BATCH' ? obj.changes : [obj];
501-
for (const change of changes) {
502-
const changeset = moveOpsToNewPool(
503-
change.changeset, (new AttribPool()).fromJsonable(change.apool), padContents.apool);
504-
505-
let changesetBack = inverse(
506-
change.changeset, padContents.currentLines, padContents.alines, padContents.apool);
507-
508-
changesetBack = moveOpsToNewPool(
509-
changesetBack, (new AttribPool()).fromJsonable(change.apool), padContents.apool);
510-
511-
loadedNewChangeset(changeset, changesetBack, change.newRev - 1, change.timeDelta);
512-
}
496+
if (obj.type === 'NEW_CHANGES') {
497+
const changeset = moveOpsToNewPool(
498+
obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
499+
500+
let changesetBack = inverse(
501+
obj.changeset, padContents.currentLines, padContents.alines, padContents.apool);
502+
503+
changesetBack = moveOpsToNewPool(
504+
changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
505+
506+
loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta);
513507
} else if (obj.type === 'NEW_AUTHORDATA') {
514508
const authorMap = {};
515509
authorMap[obj.author] = obj.data;

‎src/static/js/collab_client.ts‎

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
206206
if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM') return;
207207
const msg = wrapper.data;
208208

209-
if (msg.type === 'NEW_CHANGES' || msg.type === 'NEW_CHANGES_BATCH') {
210-
// NEW_CHANGES_BATCH (added in #7756 lever 3b) carries an array of
211-
// revisions in one emit. Each revision has the same shape as the
212-
// legacy single-rev message, so we normalise to a list and apply in
213-
// order, sharing the same compose-safety await.
214-
const changes = msg.type === 'NEW_CHANGES_BATCH' ? msg.changes : [msg];
209+
if (msg.type === 'NEW_CHANGES') {
215210
serverMessageTaskQueue.enqueue(async () => {
216211
// Avoid updating the DOM while the user is composing a character. Notes about this `await`:
217212
// * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
@@ -221,16 +216,14 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
221216
// possible, that the chances are so small or the consequences so minor that it's not
222217
// worth addressing).
223218
await editor.getInInternationalComposition();
224-
for (const change of changes) {
225-
const {newRev, changeset, author = '', apool} = change;
226-
if (newRev !== (rev + 1)) {
227-
window.console.warn(`bad message revision on ${msg.type}: ${newRev} not ${rev + 1}`);
228-
// setChannelState("DISCONNECTED", "badmessage_newchanges");
229-
return;
230-
}
231-
rev = newRev;
232-
editor.applyChangesToBase(changeset, author, apool);
219+
const {newRev, changeset, author = '', apool} = msg;
220+
if (newRev !== (rev + 1)) {
221+
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
222+
// setChannelState("DISCONNECTED", "badmessage_newchanges");
223+
return;
233224
}
225+
rev = newRev;
226+
editor.applyChangesToBase(changeset, author, apool);
234227
});
235228
} else if (msg.type === 'ACCEPT_COMMIT') {
236229
serverMessageTaskQueue.enqueue(() => {

‎src/static/js/types/SocketIOMessage.ts‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,20 +128,6 @@ export type ClientNewChanges = {
128128
payload?: ClientNewChanges
129129
}
130130

131-
export type NewChangesItem = {
132-
apool: AttributePool,
133-
author: string,
134-
changeset: string,
135-
currentTime: number,
136-
newRev: number,
137-
timeDelta: number,
138-
}
139-
140-
export type ClientNewChangesBatch = {
141-
type: 'NEW_CHANGES_BATCH',
142-
changes: NewChangesItem[],
143-
}
144-
145131
export type ClientAcceptCommitMessage = {
146132
type: 'ACCEPT_COMMIT'
147133
newRev: number

‎src/tests/backend-new/specs/new-changes-batch.test.ts‎

Lines changed: 0 additions & 48 deletions
This file was deleted.

0 commit comments

Comments
 (0)