You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two authorized CLIENT_READY requests on one socket can finish out of order, leaving the socket in both rooms and allowing the older request to overwrite the new revision state.
Track request order before asynchronous work and commit the join generation after access is granted. Superseded joins discard their remaining handshake/reconnect output; revision updates also stop when their join generation changes. A new authorized join leaves the previous room and preserves the existing last-socket-per-author leave notification behavior. A later denied request leaves the earlier authorized join valid.
Added 14 deterministic regressions using real Socket.IO connections and controlled hook/DB pauses. On the original implementation, 13 fail; all 14 pass with the fix. Updated the hook documentation in both formats.
Validation on Windows, Node 24.22.0:
72 focused/socket/security tests passed; full backend: 1,909 passed, 22 pending. Five shell-script tests fail identically on the original implementation (1,895 passed there).
Vitest: 856 passed, one skipped. TypeScript check, admin/login build and VitePress documentation build passed.
Complete Chromium/Firefox rerun: 570 passed, one flaky test passed on retry, three skipped, exit 0. The scroll test also flakes on the original implementation. The first full run additionally failed the rename-input test; it passed on the full rerun and on the original full suite (571 passed, three skipped).
pnpm run lint cannot start on either version: the locked ESLint 10 needs a flat config, while the repository provides .eslintrc.cjs. The locked TypeScript ESLint parser also rejects TS 7. The legacy AsciiDoc build requires an unavailable asciidoctor; the VitePress build succeeds.
Prepared with Codex.
UI changes
This PR doesn't add or change anything users see or interact with.
This PR changes the UI, and I clicked through it myself in a browser and I'm happy with the UI/UX. If an AI tool wrote any of it, only tick this after you've tried it yourself.
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing
• Prevent older authorized joins from overwriting a newer join’s room, snapshot, or revision state.
• Leave the previous room on an authorized join while preserving last-socket-per-author leave
notifications.
• Add deterministic socket regressions and document how superseded joins affect hooks.
Diagram
graph TD
A["CLIENT_READY"] --> B["Access check"] --> C{"Access granted?"} -->|Yes| E["Commit generation and swap rooms"] --> F{"Current generation?"} -->|Yes| H["Publish snapshot and revisions"]
C -->|No| D["Retain prior join"]
F -->|No| G["Discard stale work"]
Loading
High-Level Assessment
The following are alternative approaches to this PR:
1. Serialize CLIENT_READY requests per socket
➕ Removes overlapping join handshakes and simplifies ordering.
➖ A slow hook or database call blocks newer requests, including requests that should supersede it.
2. Cancel earlier asynchronous work
➕ Could avoid some unnecessary work after a newer join succeeds.
➖ Existing plugin hooks and database calls do not share a reliable cancellation contract; output and revision guards would still be needed.
Recommendation: Keep the generation-based approach. It lets a newer authorized join proceed without waiting for older hooks, preserves an earlier join when a later request is denied, and guards work that cannot be cancelled.
Files changed (4) +416 / -21
Bug fix (1) +58 / -21
PadMessageHandler.tsGuard asynchronous joins and revision updates by join generation+58/-21
Guard asynchronous joins and revision updates by join generation
• Reserves request order before asynchronous work, commits a generation only after access is granted, and drops superseded handshake or reconnect output. An authorized move leaves the previous room using shared leave-notification logic; snapshot baselines and in-flight revision updates are guarded against later joins or disconnects.
hooks_server-side.adocDocument superseded joins and leave-hook behavior in AsciiDoc+11/-0
Document superseded joins and leave-hook behavior in AsciiDoc
• Explains when a later authorized CLIENT_READY discards earlier hook results and handshake output. Clarifies that moving pads triggers userLeave only when the author’s last socket leaves.
1. A failing leave hook strands the socket with no pad 🐞 Bug☼ Reliability
Description
In the pad-move path, handleMessage leaves the old room and commits the target session before
awaiting notifyUserLeave, which performs an author-color lookup and runs the userLeave plugin
hook. If either rejects, the new join stops before handleClientReady, leaving the socket outside
both rooms and without CLIENT_VARS; a slow hook also delays every pad switch.
+ if (previousSession && previousSession.padId !== pending.padId) {+ await notifyUserLeave(socket, previousSession);+ if (!isCurrentJoin()) return;+ }
Evidence
The new awaited notifyUserLeave call sits after removal from the old room and before the security
and message hooks and handleClientReady. Its author-color lookup and userLeave hook can reject,
and no handler catches those errors, so they reject handleMessage for CLIENT_READY before the
new-room handshake runs. Previously, this notification ran only from handleDisconnect.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
A previous-pad leave notification runs inline during a CLIENT_READY pad move, after the old room has been left and the target session committed. A rejection aborts the new join, while a slow `userLeave` hook delays it.
## Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[600-615]
- src/node/handler/PadMessageHandler.ts[294-318]
## Recommended Fix
Handle `notifyUserLeave` independently of join completion: start it without awaiting and attach a `.catch` that logs the error with its traceback through `messageLogger.error`. Continue the new handshake, retaining the `isCurrentJoin()` check and the existing last-socket presence check.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
2. A plugin-dropped join leaves the socket in no room 🐞 Bug≡ Correctness
Description
handleMessage leaves the previous pad room and commits the new session before the
handleMessageSecurity and handleMessage hooks decide whether to accept CLIENT_READY. If either
hook throws or handleMessage returns null, the handler does not reach the new room join or send
CLIENT_VARS, leaving the connected socket in neither pad room.
+ const previousSession = thisSession.padId && socket.rooms.has(thisSession.padId)+ ? {...thisSession} : null;+ // Stop old-room updates while preparing the new snapshot, including same-pad rejoins.+ if (previousSession) socket.leave(previousSession.padId);+ thisSession.joinGeneration = joinGeneration;
Evidence
The transition leaves the previous room and commits joinGeneration at lines 601–605, before the
message hooks run. The handleMessage hook can return early at lines 660–663, before
handleClientReady joins the target room; a hook failure can likewise prevent that join after the
previous room has been left.
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
## Issue description
On a CLIENT_READY move, the handler leaves the previous room and commits the new session before the message hooks accept the request. If a hook drops or throws, the socket does not complete the new join.
## Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[600-615]
- src/node/handler/PadMessageHandler.ts[639-669]
## Recommended Fix
Run the message hooks before committing the pending join or leaving the previous room. Once the hooks accept the request, recheck the join generation and session identity, then perform the room transition before starting the handshake.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two authorized
CLIENT_READYrequests on one socket can finish out of order, leaving the socket in both rooms and allowing the older request to overwrite the new revision state.Track request order before asynchronous work and commit the join generation after access is granted. Superseded joins discard their remaining handshake/reconnect output; revision updates also stop when their join generation changes. A new authorized join leaves the previous room and preserves the existing last-socket-per-author leave notification behavior. A later denied request leaves the earlier authorized join valid.
Fixes #8460.
Added 14 deterministic regressions using real Socket.IO connections and controlled hook/DB pauses. On the original implementation, 13 fail; all 14 pass with the fix. Updated the hook documentation in both formats.
Validation on Windows, Node 24.22.0:
pnpm run lintcannot start on either version: the locked ESLint 10 needs a flat config, while the repository provides.eslintrc.cjs. The locked TypeScript ESLint parser also rejects TS 7. The legacy AsciiDoc build requires an unavailableasciidoctor; the VitePress build succeeds.Prepared with Codex.
UI changes