Repository navigation
Description
Describe the bug
Since #7480, handleClientReady() ends with await exports.updatePadClients(pad); (src/node/handler/PadMessageHandler.ts; v3.3.9 / develop a2cfa88a5, ~L1503). updatePadClients(pad) walks all sockets of the pad and advances each sessioninfo.rev up to the head revision. This call runs outside the per-pad padChannels queue that serializes handleUserChanges(). This function appends the revision and then, after further awaits, checks assert.equal(thisSession.rev, r);.
If another client joins the pad in that window, the join catch-up sends the author's own new revision to them as NEW_CHANGES and moves thisSession.rev to r + 1. This causes the assert to fail, and the server sends {disconnect: 'badChangeset'}. The author's client reconnects, and this reconnect is itself a join that can hit the next writer. With many people in one pad this snowballs into a reconnect cascade (lost keystrokes, dead clicks, users constantly rejoining).
To Reproduce
This has been tested without plugins on default settings. Claude Code has generated a testing file called joinCatchUpRace.ts (drop into src/tests/backend/specs/) (attached to this issue) to reproduce the bug. It uses the existing backend test helpers (tests/backend/common.ts), so it runs inside an Etherpad checkout like the other backend specs.
Steps to reproduce the behavior:
- Client A joins the pad and sends a
USER_CHANGES. - The test holds A's
handleUserChanges()right afterpad.appendRevision()(wrappingappendRevisionon the pad instance). - Client B joins the pad meanwhile; its catch-up
updatePadClients()runs. - A's change is released. Expected
ACCEPT_COMMIT, actual:AssertionError: writer got ["NEW_CHANGES","USER_NEWINFO","disconnect:badChangeset"], and the server logs:[WARN] message - Failed to apply USER_CHANGES from author a.authorid (socket socketid) on pad padid: AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 2 !== 1
In production we see the same log line many times per session with ~10–20 people in one pad. Every occurrence coincides within ~100 ms with a join on the same pad, and each failure triggers a reconnect that is again a join.
Expected behavior
A joining client must not change the revision state of other sessions. Only the joining socket should be caught up.
Server:
- Etherpad version: 3.3.8 (identical code in 3.3.9 and develop)
- OS: Debian 12
- Node.js version: as shipped in the official Docker image
- npm version: as shipped in the official Docker image
- Is the server free of plugins: the production instance is not plugin-free, it runs about 16 plugins, of which none hook into the affected code path. Claude Code has verified this bug on a plugin-free testing instance.
- Are you using any abstraction IE docker? Yes, the official image, inside rootless Docker, behind an Apache reverse proxy.
Desktop:
- Mixed. We were using different devices and operating systems (Windows 11, Linux (Ubuntu, Fedora, Artix)) and different browsers. This is a browser-independent bug.
Suggested fix:
Claude suggests to fix this bug by changing two places in PadMessageHandler.ts:
-exports.updatePadClients = async (pad: PadType) => {
+exports.updatePadClients = async (pad: PadType, onlySocketId?: string) => {
// skip this if no-one is on this pad
- const roomSockets = _getRoomSockets(pad.id);
+ let roomSockets = _getRoomSockets(pad.id);
+ if (onlySocketId != null) roomSockets = roomSockets.filter((s) => s.id === onlySocketId);
if (roomSockets.length === 0) return;
@@ handleClientReady
- await exports.updatePadClients(pad);
+ await exports.updatePadClients(pad, socket.id);With this change the test passes and the full backend suite stays green (1875 passing). Running the catch-up through the pad’s padChannels queue would be a more thorough alternative.
Additional context
- Etherpad version 2.2.7 (the version that ran on my server earlier) is not affected by this bug.
- Related issues: Applied attributes can cause apply mismatched. Needs load test coverage to replicate. #4040 (the issue fix: capture head revision atomically with atext to prevent mismatched apply #7480 fixed), security(collab): apply queued USER_CHANGES to the enqueue-time pad (cross-pad write TOCTOU) #8075 (queued
USER_CHANGESbinding), Performance: [#7756 follow-up] Room-broadcast NEW_CHANGES fan-out to drop ~5-7% per-recipient packet construction #7780 (room-broadcast fan-out; a rework of updatePadClients should keep the join catch-up per-socket), feat(scaling): serialize per-socket fan-out + NEW_CHANGES_BATCH (#7756 lever 3) #7768 / feat(scaling): serialize per-socket fan-out + NEW_CHANGES_BATCH #7881 (serialized per-socket fan-out, closed unmerged). - Claude Code (Opus 5.5) assisted me by identifying this bug and filling out the bug report template. Since I am not a native english speaker, I apologize for any misspelled words or weird grammar.
Activity
Raj85446696 commentedon Oct 31, 2026
i fix the issue