Skip to content

fix: capture head revision atomically with atext to prevent mismatched apply#7480

Merged
JohnMcLear merged 6 commits intodevelopfrom
fix/mismatched-apply-race-4040
Apr 7, 2026
Merged

fix: capture head revision atomically with atext to prevent mismatched apply#7480
JohnMcLear merged 6 commits intodevelopfrom
fix/mismatched-apply-race-4040

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

Summary

  • When constructing CLIENT_VARS for a newly connecting client, pad.atext was cloned at one point and pad.getHeadRevisionNumber() was called later
  • If concurrent edits advanced the revision between these two reads, the client received initialAttributedText from rev N but rev: N+3
  • The client initialized its editor with rev N text but thought it was at rev N+3
  • When the next NEW_CHANGES arrived (for rev N+4), its changeset expected rev N+3 text but the client had rev N text, causing "mismatched apply: X / Y"
  • Fix: capture headRev at the same time as atext and use it consistently

Root Cause

Line 989:  atext = cloneAText(pad.atext)     ← captures text at rev N
           ... (concurrent edits advance pad to rev N+3) ...
Line 1019: rev: pad.getHeadRevisionNumber()  ← returns N+3, not N!
Line 1085: sessionInfo.rev = pad.getHeadRevisionNumber() ← also N+3

The client thinks it's at N+3 with rev N text. Next changeset for N+4 expects N+3 text → boom.

Test plan

  • Type check passes
  • Backend test: verifies CLIENT_VARS rev matches initialAttributedText state
  • All 753 backend tests pass

Fixes #4040

🤖 Generated with Claude Code

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Fix atomic capture of head revision to prevent mismatched apply errors

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Fixes race condition where concurrent edits caused revision mismatch
• Captures head revision atomically with atext to ensure consistency
• Prevents "mismatched apply" errors on client initialization
• Adds regression test verifying CLIENT_VARS rev matches initialAttributedText
Diagram
flowchart LR
  A["Client connects"] --> B["Capture atext at rev N"]
  B --> C["Concurrent edits advance to rev N+3"]
  C --> D["Old: capture rev N+3 separately"]
  D --> E["Mismatch: text from N, rev N+3"]
  E --> F["Next changeset fails"]
  
  A --> G["New: capture atext AND headRev together"]
  G --> H["Both from same revision N"]
  H --> I["Consistent: text and rev both N"]
  I --> J["Next changeset succeeds"]
Loading

Grey Divider

File Changes

1. src/node/handler/PadMessageHandler.ts 🐞 Bug fix +10/-3

Atomic capture of head revision with atext

• Declares headRev variable to capture revision atomically with atext
• Moves pad.getHeadRevisionNumber() call before cloneAText() to capture both at same point
• Replaces subsequent pad.getHeadRevisionNumber() calls with captured headRev value
• Updates comments to clarify revision consistency requirement

src/node/handler/PadMessageHandler.ts


2. src/tests/backend/specs/clientvar_rev_consistency.ts 🧪 Tests +57/-0

Regression test for CLIENT_VARS rev consistency

• New regression test for issue #4040 verifying CLIENT_VARS consistency
• Creates pad with multiple edits to advance revision
• Connects new client and validates initialAttributedText matches rev
• Ensures text state corresponds to reported revision number

src/tests/backend/specs/clientvar_rev_consistency.ts


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 6, 2026

Code Review by Qodo

🐞 Bugs (4)   📘 Rule violations (0)   📎 Requirement gaps (4)   🎨 UX Issues (0)
🐞\ ≡ Correctness (3) ⚙ Maintainability (1)
📎\ ☼ Reliability (4)

Grey Divider


Action required

1. No loadTesting reproduction test📎
Description
The added regression test verifies CLIENT_VARS consistency but does not enable loadTesting or
simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply"
failure mode under load. This fails the requirement to add automated load-test coverage for the
specific edge case.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R23-51]

+  it('CLIENT_VARS rev matches initialAttributedText state', async function () {
+    this.timeout(30000);
+    const padId = randomString(10);
+
+    // Create a pad with initial text
+    const pad = await padManager.getPad(padId, 'initial text\n');
+
+    // Make several edits to advance the revision
+    await pad.setText('edit one\n');
+    await pad.setText('edit two\n');
+    await pad.setText('edit three\n');
+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
PR Compliance ID 1 requires an automated load test scenario with loadTesting enabled and active
concurrent edits; the new test only performs a few sequential setText() calls and asserts
initialAttributedText.text matches pad.text(), with no loadTesting enablement or high-rate
editing during pad open/handshake.

Add load test coverage that reproduces the mismatched apply failure on very active pads
src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds a regression test, but it does not reproduce the reported production failure mode under `loadTesting` (very active pads with concurrent edits while a client loads), as required.
## Issue Context
The compliance requirement expects an automated scenario that can trigger the `mismatched apply` failure before the fix and demonstrate non-failure after the fix, specifically when `loadTesting` is enabled and edits occur at high frequency during pad load.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. No CLIENT_VARS delay test📎
Description
The new test does not simulate or introduce any timing/delay or race between CLIENT_VARS delivery
and subsequent NEW_CHANGES reception. This leaves the suspected race condition scenario untested.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R36-52]

+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
+    } finally {
Evidence
PR Compliance ID 4 requires a test that explicitly simulates timing/delay between CLIENT_VARS and
NEW_CHANGES; the added test only performs a handshake to receive CLIENT_VARS and asserts text
equality, without creating delayed delivery or handling/applying NEW_CHANGES afterward.

Add a test scenario that simulates timing/delay between CLIENT_VARS delivery and NEW_CHANGES reception
src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is no test coverage that introduces a delay/race between receiving `CLIENT_VARS` and receiving/applying `NEW_CHANGES`, which is part of the reported failure mechanism.
## Issue Context
The bug manifests when `CLIENT_VARS` state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no `mismatched apply` occurs.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Connect can miss revisions🐞
Description
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions
created during that await are broadcast to existing room members but not to the connecting socket.
Because there is no explicit catch-up after joining, the new client can remain stuck on an older
revision until some later edit triggers updatePadClients().
Code

src/node/handler/PadMessageHandler.ts[R1088-1092]

// Send the clientVars to the Client
socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});
-    // Save the current revision in sessioninfos, should be the same as in clientVars
-    sessionInfo.rev = pad.getHeadRevisionNumber();
+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
Evidence
handleClientReady awaits the clientVars hook before joining the pad room and sending CLIENT_VARS;
any edits during that await will call updatePadClients(), which only emits NEW_CHANGES to sockets
currently in the room, so the connecting socket won't receive those changes. There is no
updatePadClients() call after the socket joins to proactively deliver any revisions missed during
the await window.

src/node/handler/PadMessageHandler.ts[1077-1093]
src/node/handler/PadMessageHandler.ts[705-728]
src/node/handler/PadMessageHandler.ts[738-795]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During `handleClientReady()` (first connect), the socket does not join the pad room until after `await hooks.aCallAll('clientVars', ...)`. Any revisions appended while awaiting the hook are sent to existing room members via `updatePadClients()`, but the connecting socket is not yet in `_getRoomSockets(pad.id)` and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
### Issue Context
`updatePadClients()` is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1077-1093]
### Proposed fix
After `socket.join(sessionInfo.padId)` and after sending `CLIENT_VARS` (so the client is initialized), explicitly send any missing revisions:
1) Initialize `sessionInfo.time` consistently for the snapshot revision (for correct `timeDelta` computation), ideally using `await pad.getRevisionDate(headRev)` (and consider also using this timestamp in `clientVars.collab_client_vars.time`).
2) Call `await exports.updatePadClients(pad)` once after `sessionInfo.rev = headRev` (and after `sessionInfo.time` is set) to flush any revisions that occurred between snapshot capture and the join.
This ensures that if the pad advanced during the hook await window, the new client receives `NEW_CHANGES` immediately, even if the pad becomes quiet afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (15)
4. No loadTesting reproduction test📎
Description
The added regression test verifies CLIENT_VARS consistency but does not enable loadTesting or
simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply"
failure mode under load. This fails the requirement to add automated load-test coverage for the
specific edge case.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R23-51]

+  it('CLIENT_VARS rev matches initialAttributedText state', async function () {
+    this.timeout(30000);
+    const padId = randomString(10);
+
+    // Create a pad with initial text
+    const pad = await padManager.getPad(padId, 'initial text\n');
+
+    // Make several edits to advance the revision
+    await pad.setText('edit one\n');
+    await pad.setText('edit two\n');
+    await pad.setText('edit three\n');
+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
PR Compliance ID 1 requires an automated load test scenario with loadTesting enabled and active
concurrent edits; the new test only performs a few sequential setText() calls and asserts
initialAttributedText.text matches pad.text(), with no loadTesting enablement or high-rate
editing during pad open/handshake.

Add load test coverage that reproduces the mismatched apply failure on very active pads
src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds a regression test, but it does not reproduce the reported production failure mode under `loadTesting` (very active pads with concurrent edits while a client loads), as required.
## Issue Context
The compliance requirement expects an automated scenario that can trigger the `mismatched apply` failure before the fix and demonstrate non-failure after the fix, specifically when `loadTesting` is enabled and edits occur at high frequency during pad load.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. No CLIENT_VARS delay test📎
Description
The new test does not simulate or introduce any timing/delay or race between CLIENT_VARS delivery
and subsequent NEW_CHANGES reception. This leaves the suspected race condition scenario untested.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R36-52]

+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
+    } finally {
Evidence
PR Compliance ID 4 requires a test that explicitly simulates timing/delay between CLIENT_VARS and
NEW_CHANGES; the added test only performs a handshake to receive CLIENT_VARS and asserts text
equality, without creating delayed delivery or handling/applying NEW_CHANGES afterward.

Add a test scenario that simulates timing/delay between CLIENT_VARS delivery and NEW_CHANGES reception
src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is no test coverage that introduces a delay/race between receiving `CLIENT_VARS` and receiving/applying `NEW_CHANGES`, which is part of the reported failure mechanism.
## Issue Context
The bug manifests when `CLIENT_VARS` state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no `mismatched apply` occurs.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Connect can miss revisions🐞
Description
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions
created during that await are broadcast to existing room members but not to the connecting socket.
Because there is no explicit catch-up after joining, the new client can remain stuck on an older
revision until some later edit triggers updatePadClients().
Code

src/node/handler/PadMessageHandler.ts[R1088-1092]

// Send the clientVars to the Client
socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});
-    // Save the current revision in sessioninfos, should be the same as in clientVars
-    sessionInfo.rev = pad.getHeadRevisionNumber();
+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
Evidence
handleClientReady awaits the clientVars hook before joining the pad room and sending CLIENT_VARS;
any edits during that await will call updatePadClients(), which only emits NEW_CHANGES to sockets
currently in the room, so the connecting socket won't receive those changes. There is no
updatePadClients() call after the socket joins to proactively deliver any revisions missed during
the await window.

src/node/handler/PadMessageHandler.ts[1077-1093]
src/node/handler/PadMessageHandler.ts[705-728]
src/node/handler/PadMessageHandler.ts[738-795]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During `handleClientReady()` (first connect), the socket does not join the pad room until after `await hooks.aCallAll('clientVars', ...)`. Any revisions appended while awaiting the hook are sent to existing room members via `updatePadClients()`, but the connecting socket is not yet in `_getRoomSockets(pad.id)` and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
### Issue Context
`updatePadClients()` is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1077-1093]
### Proposed fix
After `socket.join(sessionInfo.padId)` and after sending `CLIENT_VARS` (so the client is initialized), explicitly send any missing revisions:
1) Initialize `sessionInfo.time` consistently for the snapshot revision (for correct `timeDelta` computation), ideally using `await pad.getRevisionDate(headRev)` (and consider also using this timestamp in `clientVars.collab_client_vars.time`).
2) Call `await exports.updatePadClients(pad)` once after `sessionInfo.rev = headRev` (and after `sessionInfo.time` is set) to flush any revisions that occurred between snapshot capture and the join.
This ensures that if the pad advanced during the hook await window, the new client receives `NEW_CHANGES` immediately, even if the pad becomes quiet afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. No loadTesting reproduction test📎
Description
The added regression test verifies CLIENT_VARS consistency but does not enable loadTesting or
simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply"
failure mode under load. This fails the requirement to add automated load-test coverage for the
specific edge case.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R23-51]

+  it('CLIENT_VARS rev matches initialAttributedText state', async function () {
+    this.timeout(30000);
+    const padId = randomString(10);
+
+    // Create a pad with initial text
+    const pad = await padManager.getPad(padId, 'initial text\n');
+
+    // Make several edits to advance the revision
+    await pad.setText('edit one\n');
+    await pad.setText('edit two\n');
+    await pad.setText('edit three\n');
+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
PR Compliance ID 1 requires an automated load test scenario with loadTesting enabled and active
concurrent edits; the new test only performs a few sequential setText() calls and asserts
initialAttributedText.text matches pad.text(), with no loadTesting enablement or high-rate
editing during pad open/handshake.

Add load test coverage that reproduces the mismatched apply failure on very active pads
src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds a regression test, but it does not reproduce the reported production failure mode under `loadTesting` (very active pads with concurrent edits while a client loads), as required.
## Issue Context
The compliance requirement expects an automated scenario that can trigger the `mismatched apply` failure before the fix and demonstrate non-failure after the fix, specifically when `loadTesting` is enabled and edits occur at high frequency during pad load.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. No CLIENT_VARS delay test📎
Description
The new test does not simulate or introduce any timing/delay or race between CLIENT_VARS delivery
and subsequent NEW_CHANGES reception. This leaves the suspected race condition scenario untested.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R36-52]

+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
+    } finally {
Evidence
PR Compliance ID 4 requires a test that explicitly simulates timing/delay between CLIENT_VARS and
NEW_CHANGES; the added test only performs a handshake to receive CLIENT_VARS and asserts text
equality, without creating delayed delivery or handling/applying NEW_CHANGES afterward.

Add a test scenario that simulates timing/delay between CLIENT_VARS delivery and NEW_CHANGES reception
src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is no test coverage that introduces a delay/race between receiving `CLIENT_VARS` and receiving/applying `NEW_CHANGES`, which is part of the reported failure mechanism.
## Issue Context
The bug manifests when `CLIENT_VARS` state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no `mismatched apply` occurs.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Connect can miss revisions🐞
Description
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions
created during that await are broadcast to existing room members but not to the connecting socket.
Because there is no explicit catch-up after joining, the new client can remain stuck on an older
revision until some later edit triggers updatePadClients().
Code

src/node/handler/PadMessageHandler.ts[R1088-1092]

// Send the clientVars to the Client
socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});
-    // Save the current revision in sessioninfos, should be the same as in clientVars
-    sessionInfo.rev = pad.getHeadRevisionNumber();
+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
Evidence
handleClientReady awaits the clientVars hook before joining the pad room and sending CLIENT_VARS;
any edits during that await will call updatePadClients(), which only emits NEW_CHANGES to sockets
currently in the room, so the connecting socket won't receive those changes. There is no
updatePadClients() call after the socket joins to proactively deliver any revisions missed during
the await window.

src/node/handler/PadMessageHandler.ts[1077-1093]
src/node/handler/PadMessageHandler.ts[705-728]
src/node/handler/PadMessageHandler.ts[738-795]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During `handleClientReady()` (first connect), the socket does not join the pad room until after `await hooks.aCallAll('clientVars', ...)`. Any revisions appended while awaiting the hook are sent to existing room members via `updatePadClients()`, but the connecting socket is not yet in `_getRoomSockets(pad.id)` and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
### Issue Context
`updatePadClients()` is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1077-1093]
### Proposed fix
After `socket.join(sessionInfo.padId)` and after sending `CLIENT_VARS` (so the client is initialized), explicitly send any missing revisions:
1) Initialize `sessionInfo.time` consistently for the snapshot revision (for correct `timeDelta` computation), ideally using `await pad.getRevisionDate(headRev)` (and consider also using this timestamp in `clientVars.collab_client_vars.time`).
2) Call `await exports.updatePadClients(pad)` once after `sessionInfo.rev = headRev` (and after `sessionInfo.time` is set) to flush any revisions that occurred between snapshot capture and the join.
This ensures that if the pad advanced during the hook await window, the new client receives `NEW_CHANGES` immediately, even if the pad becomes quiet afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. No loadTesting reproduction test📎
Description
The added regression test verifies CLIENT_VARS consistency but does not enable loadTesting or
simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply"
failure mode under load. This fails the requirement to add automated load-test coverage for the
specific edge case.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R23-51]

+  it('CLIENT_VARS rev matches initialAttributedText state', async function () {
+    this.timeout(30000);
+    const padId = randomString(10);
+
+    // Create a pad with initial text
+    const pad = await padManager.getPad(padId, 'initial text\n');
+
+    // Make several edits to advance the revision
+    await pad.setText('edit one\n');
+    await pad.setText('edit two\n');
+    await pad.setText('edit three\n');
+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
PR Compliance ID 1 requires an automated load test scenario with loadTesting enabled and active
concurrent edits; the new test only performs a few sequential setText() calls and asserts
initialAttributedText.text matches pad.text(), with no loadTesting enablement or high-rate
editing during pad open/handshake.

Add load test coverage that reproduces the mismatched apply failure on very active pads
src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds a regression test, but it does not reproduce the reported production failure mode under `loadTesting` (very active pads with concurrent edits while a client loads), as required.
## Issue Context
The compliance requirement expects an automated scenario that can trigger the `mismatched apply` failure before the fix and demonstrate non-failure after the fix, specifically when `loadTesting` is enabled and edits occur at high frequency during pad load.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. No CLIENT_VARS delay test📎
Description
The new test does not simulate or introduce any timing/delay or race between CLIENT_VARS delivery
and subsequent NEW_CHANGES reception. This leaves the suspected race condition scenario untested.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R36-52]

+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
+    } finally {
Evidence
PR Compliance ID 4 requires a test that explicitly simulates timing/delay between CLIENT_VARS and
NEW_CHANGES; the added test only performs a handshake to receive CLIENT_VARS and asserts text
equality, without creating delayed delivery or handling/applying NEW_CHANGES afterward.

Add a test scenario that simulates timing/delay between CLIENT_VARS delivery and NEW_CHANGES reception
src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is no test coverage that introduces a delay/race between receiving `CLIENT_VARS` and receiving/applying `NEW_CHANGES`, which is part of the reported failure mechanism.
## Issue Context
The bug manifests when `CLIENT_VARS` state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no `mismatched apply` occurs.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Connect can miss revisions🐞
Description
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions
created during that await are broadcast to existing room members but not to the connecting socket.
Because there is no explicit catch-up after joining, the new client can remain stuck on an older
revision until some later edit triggers updatePadClients().
Code

src/node/handler/PadMessageHandler.ts[R1088-1092]

// Send the clientVars to the Client
socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});
-    // Save the current revision in sessioninfos, should be the same as in clientVars
-    sessionInfo.rev = pad.getHeadRevisionNumber();
+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
Evidence
handleClientReady awaits the clientVars hook before joining the pad room and sending CLIENT_VARS;
any edits during that await will call updatePadClients(), which only emits NEW_CHANGES to sockets
currently in the room, so the connecting socket won't receive those changes. There is no
updatePadClients() call after the socket joins to proactively deliver any revisions missed during
the await window.

src/node/handler/PadMessageHandler.ts[1077-1093]
src/node/handler/PadMessageHandler.ts[705-728]
src/node/handler/PadMessageHandler.ts[738-795]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During `handleClientReady()` (first connect), the socket does not join the pad room until after `await hooks.aCallAll('clientVars', ...)`. Any revisions appended while awaiting the hook are sent to existing room members via `updatePadClients()`, but the connecting socket is not yet in `_getRoomSockets(pad.id)` and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
### Issue Context
`updatePadClients()` is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1077-1093]
### Proposed fix
After `socket.join(sessionInfo.padId)` and after sending `CLIENT_VARS` (so the client is initialized), explicitly send any missing revisions:
1) Initialize `sessionInfo.time` consistently for the snapshot revision (for correct `timeDelta` computation), ideally using `await pad.getRevisionDate(headRev)` (and consider also using this timestamp in `clientVars.collab_client_vars.time`).
2) Call `await exports.updatePadClients(pad)` once after `sessionInfo.rev = headRev` (and after `sessionInfo.time` is set) to flush any revisions that occurred between snapshot capture and the join.
This ensures that if the pad advanced during the hook await window, the new client receives `NEW_CHANGES` immediately, even if the pad becomes quiet afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. No loadTesting reproduction test 📎
Description
The added regression test verifies CLIENT_VARS consistency but does not enable loadTesting or
simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply"
failure mode under load. This fails the requirement to add automated load-test coverage for the
specific edge case.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R23-51]

+  it('CLIENT_VARS rev matches initialAttributedText state', async function () {
+    this.timeout(30000);
+    const padId = randomString(10);
+
+    // Create a pad with initial text
+    const pad = await padManager.getPad(padId, 'initial text\n');
+
+    // Make several edits to advance the revision
+    await pad.setText('edit one\n');
+    await pad.setText('edit two\n');
+    await pad.setText('edit three\n');
+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
PR Compliance ID 1 requires an automated load test scenario with loadTesting enabled and active
concurrent edits; the new test only performs a few sequential setText() calls and asserts
initialAttributedText.text matches pad.text(), with no loadTesting enablement or high-rate
editing during pad open/handshake.

Add load test coverage that reproduces the mismatched apply failure on very active pads
src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds a regression test, but it does not reproduce the reported production failure mode under `loadTesting` (very active pads with concurrent edits while a client loads), as required.
## Issue Context
The compliance requirement expects an automated scenario that can trigger the `mismatched apply` failure before the fix and demonstrate non-failure after the fix, specifically when `loadTesting` is enabled and edits occur at high frequency during pad load.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. No CLIENT_VARS delay test 📎
Description
The new test does not simulate or introduce any timing/delay or race between CLIENT_VARS delivery
and subsequent NEW_CHANGES reception. This leaves the suspected race condition scenario untested.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R36-52]

+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
+    } finally {
Evidence
PR Compliance ID 4 requires a test that explicitly simulates timing/delay between CLIENT_VARS and
NEW_CHANGES; the added test only performs a handshake to receive CLIENT_VARS and asserts text
equality, without creating delayed delivery or handling/applying NEW_CHANGES afterward.

Add a test scenario that simulates timing/delay between CLIENT_VARS delivery and NEW_CHANGES reception
src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is no test coverage that introduces a delay/race between receiving `CLIENT_VARS` and receiving/applying `NEW_CHANGES`, which is part of the reported failure mechanism.
## Issue Context
The bug manifests when `CLIENT_VARS` state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no `mismatched apply` occurs.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Connect can miss revisions 🐞
Description
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions
created during that await are broadcast to existing room members but not to the connecting socket.
Because there is no explicit catch-up after joining, the new client can remain stuck on an older
revision until some later edit triggers updatePadClients().
Code

src/node/handler/PadMessageHandler.ts[R1088-1092]

 // Send the clientVars to the Client
 socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});
-    // Save the current revision in sessioninfos, should be the same as in clientVars
-    sessionInfo.rev = pad.getHeadRevisionNumber();
+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
Evidence
handleClientReady awaits the clientVars hook before joining the pad room and sending CLIENT_VARS;
any edits during that await will call updatePadClients(), which only emits NEW_CHANGES to sockets
currently in the room, so the connecting socket won't receive those changes. There is no
updatePadClients() call after the socket joins to proactively deliver any revisions missed during
the await window.

src/node/handler/PadMessageHandler.ts[1077-1093]
src/node/handler/PadMessageHandler.ts[705-728]
src/node/handler/PadMessageHandler.ts[738-795]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During `handleClientReady()` (first connect), the socket does not join the pad room until after `await hooks.aCallAll('clientVars', ...)`. Any revisions appended while awaiting the hook are sent to existing room members via `updatePadClients()`, but the connecting socket is not yet in `_getRoomSockets(pad.id)` and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
### Issue Context
`updatePadClients()` is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1077-1093]
### Proposed fix
After `socket.join(sessionInfo.padId)` and after sending `CLIENT_VARS` (so the client is initialized), explicitly send any missing revisions:
1) Initialize `sessionInfo.time` consistently for the snapshot revision (for correct `timeDelta` computation), ideally using `await pad.getRevisionDate(headRev)` (and consider also using this timestamp in `clientVars.collab_client_vars.time`).
2) Call `await exports.updatePadClients(pad)` once after `sessionInfo.rev = headRev` (and after `sessionInfo.time` is set) to flush any revisions that occurred between snapshot capture and the join.
This ensures that if the pad advanced during the hook await window, the new client receives `NEW_CHANGES` immediately, even if the pad becomes quiet afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. No loadTesting reproduction test 📎
Description
The added regression test verifies CLIENT_VARS consistency but does not enable loadTesting or
simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply"
failure mode under load. This fails the requirement to add automated load-test coverage for the
specific edge case.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R23-51]

+  it('CLIENT_VARS rev matches initialAttributedText state', async function () {
+    this.timeout(30000);
+    const padId = randomString(10);
+
+    // Create a pad with initial text
+    const pad = await padManager.getPad(padId, 'initial text\n');
+
+    // Make several edits to advance the revision
+    await pad.setText('edit one\n');
+    await pad.setText('edit two\n');
+    await pad.setText('edit three\n');
+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
PR Compliance ID 1 requires an automated load test scenario with loadTesting enabled and active
concurrent edits; the new test only performs a few sequential setText() calls and asserts
initialAttributedText.text matches pad.text(), with no loadTesting enablement or high-rate
editing during pad open/handshake.

Add load test coverage that reproduces the mismatched apply failure on very active pads
src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds a regression test, but it does not reproduce the reported production failure mode under `loadTesting` (very active pads with concurrent edits while a client loads), as required.
## Issue Context
The compliance requirement expects an automated scenario that can trigger the `mismatched apply` failure before the fix and demonstrate non-failure after the fix, specifically when `loadTesting` is enabled and edits occur at high frequency during pad load.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. No CLIENT_VARS delay test 📎
Description
The new test does not simulate or introduce any timing/delay or race between CLIENT_VARS delivery
and subsequent NEW_CHANGES reception. This leaves the suspected race condition scenario untested.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R36-52]

+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
+    } finally {
Evidence
PR Compliance ID 4 requires a test that explicitly simulates timing/delay between CLIENT_VARS and
NEW_CHANGES; the added test only performs a handshake to receive CLIENT_VARS and asserts text
equality, without creating delayed delivery or handling/applying NEW_CHANGES afterward.

Add a test scenario that simulates timing/delay between CLIENT_VARS delivery and NEW_CHANGES reception
src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is no test coverage that introduces a delay/race between receiving `CLIENT_VARS` and receiving/applying `NEW_CHANGES`, which is part of the reported failure mechanism.
## Issue Context
The bug manifests when `CLIENT_VARS` state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no `mismatched apply` occurs.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Connect can miss revisions 🐞
Description
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions
created during that await are broadcast to existing room members but not to the connecting socket.
Because there is no explicit catch-up after joining, the new client can remain stuck on an older
revision until some later edit triggers updatePadClients().
Code

src/node/handler/PadMessageHandler.ts[R1088-1092]

  // Send the clientVars to the Client
  socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});
-    // Save the current revision in sessioninfos, should be the same as in clientVars
-    sessionInfo.rev = pad.getHeadRevisionNumber();
+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
Evidence
handleClientReady awaits the clientVars hook before joining the pad room and sending CLIENT_VARS;
any edits during that await will call updatePadClients(), which only emits NEW_CHANGES to sockets
currently in the room, so the connecting socket won't receive those changes. There is no
updatePadClients() call after the socket joins to proactively deliver any revisions missed during
the await window.

src/node/handler/PadMessageHandler.ts[1077-1093]
src/node/handler/PadMessageHandler.ts[705-728]
[src/node/handler/...

Comment thread src/tests/backend/specs/clientvar_rev_consistency.ts Outdated
Comment thread src/tests/backend/specs/clientvar_rev_consistency.ts
Comment thread src/node/handler/PadMessageHandler.ts
@JohnMcLear JohnMcLear marked this pull request as draft April 6, 2026 14:14
@JohnMcLear JohnMcLear force-pushed the fix/mismatched-apply-race-4040 branch from 3fc7b74 to 5bec620 Compare April 6, 2026 15:59
@JohnMcLear JohnMcLear marked this pull request as ready for review April 6, 2026 20:05
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Fix race condition in CLIENT_VARS revision consistency

🐞 Bug fix 🧪 Tests

Grey Divider

Walkthroughs

Description
• Capture head revision atomically with atext to prevent race condition
  - Concurrent edits between atext snapshot and revision read caused mismatched apply errors
  - Now captures headRev at same time as atext for consistency
• Flush missed revisions after socket joins pad room
  - Revisions appended during clientVars hook await window were missed by connecting client
  - Call updatePadClients after socket.join to deliver catch-up changesets
• Add regression tests for CLIENT_VARS revision consistency
  - Verify rev matches initialAttributedText state
  - Test catch-up changesets during clientVars hook await window
Diagram
flowchart LR
  A["Concurrent edits<br/>advance revision"] -->|"Previously: gap<br/>between reads"| B["Mismatched apply<br/>error"]
  A -->|"Now: atomic capture"| C["headRev captured<br/>with atext"]
  C --> D["CLIENT_VARS<br/>consistent"]
  D --> E["socket.join<br/>then flush revisions"]
  E --> F["Client receives<br/>all changesets"]
Loading

Grey Divider

File Changes

1. src/node/handler/PadMessageHandler.ts 🐞 Bug fix +15/-3

Atomic revision capture and missed revision flush

• Capture headRev atomically with atext before any concurrent edits can advance the revision
• Use captured headRev consistently in both CLIENT_VARS and sessionInfo.rev instead of calling
 pad.getHeadRevisionNumber() multiple times
• Call updatePadClients(pad) after socket.join() to flush revisions that were appended during
 the clientVars hook await window
• Add detailed comments explaining the race condition and fix

src/node/handler/PadMessageHandler.ts


2. src/tests/backend/specs/clientvar_rev_consistency.ts 🧪 Tests +117/-0

Regression tests for CLIENT_VARS revision consistency

• New regression test file for issue #4040
• Test 1: Verify CLIENT_VARS rev matches initialAttributedText state after multiple edits
• Test 2: Verify client receives catch-up changesets for revisions created during clientVars hook
 await window
• Install slow clientVars hook to simulate async plugin work and trigger the race condition

src/tests/backend/specs/clientvar_rev_consistency.ts


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented Apr 6, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (2) 🎨 UX Issues (0)

Grey Divider


Action required

1. No loadTesting reproduction test 📎 Requirement gap ☼ Reliability
Description
The added regression test verifies CLIENT_VARS consistency but does not enable loadTesting or
simulate high edit rates during pad load, so it does not reproduce the reported "mismatched apply"
failure mode under load. This fails the requirement to add automated load-test coverage for the
specific edge case.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R23-51]

+  it('CLIENT_VARS rev matches initialAttributedText state', async function () {
+    this.timeout(30000);
+    const padId = randomString(10);
+
+    // Create a pad with initial text
+    const pad = await padManager.getPad(padId, 'initial text\n');
+
+    // Make several edits to advance the revision
+    await pad.setText('edit one\n');
+    await pad.setText('edit two\n');
+    await pad.setText('edit three\n');
+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
PR Compliance ID 1 requires an automated load test scenario with loadTesting enabled and active
concurrent edits; the new test only performs a few sequential setText() calls and asserts
initialAttributedText.text matches pad.text(), with no loadTesting enablement or high-rate
editing during pad open/handshake.

Add load test coverage that reproduces the mismatched apply failure on very active pads
src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds a regression test, but it does not reproduce the reported production failure mode under `loadTesting` (very active pads with concurrent edits while a client loads), as required.
## Issue Context
The compliance requirement expects an automated scenario that can trigger the `mismatched apply` failure before the fix and demonstrate non-failure after the fix, specifically when `loadTesting` is enabled and edits occur at high frequency during pad load.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. No CLIENT_VARS delay test 📎 Requirement gap ☼ Reliability
Description
The new test does not simulate or introduce any timing/delay or race between CLIENT_VARS delivery
and subsequent NEW_CHANGES reception. This leaves the suspected race condition scenario untested.
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R36-52]

+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
+    } finally {
Evidence
PR Compliance ID 4 requires a test that explicitly simulates timing/delay between CLIENT_VARS and
NEW_CHANGES; the added test only performs a handshake to receive CLIENT_VARS and asserts text
equality, without creating delayed delivery or handling/applying NEW_CHANGES afterward.

Add a test scenario that simulates timing/delay between CLIENT_VARS delivery and NEW_CHANGES reception
src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is no test coverage that introduces a delay/race between receiving `CLIENT_VARS` and receiving/applying `NEW_CHANGES`, which is part of the reported failure mechanism.
## Issue Context
The bug manifests when `CLIENT_VARS` state and subsequent revisions are out of sync due to timing; the compliance requirement expects an automated test that enforces this ordering/timing stress and asserts no `mismatched apply` occurs.
## Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[36-52]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Connect can miss revisions 🐞 Bug ≡ Correctness
Description
In handleClientReady(), the server awaits the clientVars hook before socket.join(), so revisions
created during that await are broadcast to existing room members but not to the connecting socket.
Because there is no explicit catch-up after joining, the new client can remain stuck on an older
revision until some later edit triggers updatePadClients().
Code

src/node/handler/PadMessageHandler.ts[R1088-1092]

   // Send the clientVars to the Client
   socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});

-    // Save the current revision in sessioninfos, should be the same as in clientVars
-    sessionInfo.rev = pad.getHeadRevisionNumber();
+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
Evidence
handleClientReady awaits the clientVars hook before joining the pad room and sending CLIENT_VARS;
any edits during that await will call updatePadClients(), which only emits NEW_CHANGES to sockets
currently in the room, so the connecting socket won't receive those changes. There is no
updatePadClients() call after the socket joins to proactively deliver any revisions missed during
the await window.

src/node/handler/PadMessageHandler.ts[1077-1093]
src/node/handler/PadMessageHandler.ts[705-728]
src/node/handler/PadMessageHandler.ts[738-795]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
During `handleClientReady()` (first connect), the socket does not join the pad room until after `await hooks.aCallAll('clientVars', ...)`. Any revisions appended while awaiting the hook are sent to existing room members via `updatePadClients()`, but the connecting socket is not yet in `_getRoomSockets(pad.id)` and therefore misses them. Because there is no explicit catch-up after joining, the new client can remain behind until a later edit occurs.
### Issue Context
`updatePadClients()` is invoked when revisions are appended (including via other clients or API/import flows) and only targets sockets in the pad room.
### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1077-1093]
### Proposed fix
After `socket.join(sessionInfo.padId)` and after sending `CLIENT_VARS` (so the client is initialized), explicitly send any missing revisions:
1) Initialize `sessionInfo.time` consistently for the snapshot revision (for correct `timeDelta` computation), ideally using `await pad.getRevisionDate(headRev)` (and consider also using this timestamp in `clientVars.collab_client_vars.time`).
2) Call `await exports.updatePadClients(pad)` once after `sessionInfo.rev = headRev` (and after `sessionInfo.time` is set) to flush any revisions that occurred between snapshot capture and the join.
This ensures that if the pad advanced during the hook await window, the new client receives `NEW_CHANGES` immediately, even if the pad becomes quiet afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. NaN timeDelta in catch-up 🐞 Bug ≡ Correctness ⭐ New
Description
handleClientReady now calls updatePadClients() right after sending CLIENT_VARS, so a newly connected
socket can immediately receive NEW_CHANGES catch-up messages. updatePadClients computes timeDelta as
currentTime - sessioninfo.time, but sessioninfo.time is never initialized for a new session,
resulting in timeDelta=NaN which breaks timeslider/broadcast code that does currentTime +=
timeDelta.
Code

src/node/handler/PadMessageHandler.ts[R1091-1097]

+    // Save the revision in sessioninfos — must match what was sent in clientVars
+    sessionInfo.rev = headRev;
+
+    // Flush any revisions that may have been appended while we were awaiting the
+    // clientVars hook (before socket.join).  Those revisions were broadcast to
+    // existing room members but this socket hadn't joined yet so it missed them.
+    await exports.updatePadClients(pad);
Evidence
In the normal first-connect path, the PR sets sessionInfo.rev=headRev and immediately awaits
updatePadClients(pad) but never sets sessionInfo.time. updatePadClients uses sessioninfo.time to
compute timeDelta for NEW_CHANGES. On the client, the timeslider/broadcast code adds timeDelta to
currentTime; NaN will poison currentTime and produce invalid dates.

src/node/handler/PadMessageHandler.ts[1088-1098]
src/node/handler/PadMessageHandler.ts[738-795]
src/static/js/broadcast.ts[135-199]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`updatePadClients()` computes `timeDelta` as `currentTime - sessioninfo.time`. In the new initial-connect catch-up path (`handleClientReady` now calls `await updatePadClients(pad)`), `sessioninfo.time` can be `undefined`, producing `timeDelta: NaN` in `NEW_CHANGES`. Timeslider/broadcast code uses `timeDelta` to advance `currentTime`, so `NaN` will break time rendering.

### Issue Context
A new socket’s `sessioninfos[socket.id]` is initialized as `{}` and `handleClientReady` sets `sessionInfo.rev` but does not set `sessionInfo.time` before calling `updatePadClients()`.

### Fix Focus Areas
- src/node/handler/PadMessageHandler.ts[1088-1097]
- src/node/handler/PadMessageHandler.ts[738-795]

### Suggested fix
Pick one (or both):
1) Initialize `sessionInfo.time` during CLIENT_READY handling (normal connect and reconnect) to the timestamp that corresponds to `sessionInfo.rev` (often `currentTime` already computed for CLIENT_VARS).
2) Make `updatePadClients()` robust:
  - If `sessioninfo.time` is nullish, set `timeDelta` to `0` (or compute based on the revision timestamp) and/or initialize `sessioninfo.time` before computing `timeDelta`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Test doesn't validate rev 🐞 Bug ⚙ Maintainability
Description
The new regression test only compares initialAttributedText.text to pad.text() and never verifies
that initialAttributedText corresponds to collabVars.rev. This can pass even if CLIENT_VARS
advertises an incorrect revision number (the invariant that matters for preventing mismatched
apply).
Code

src/tests/backend/specs/clientvar_rev_consistency.ts[R34-51]

+    const expectedText = pad.text();
+
+    // Now connect a new client — CLIENT_VARS should be consistent
+    const res = await agent.get(`/p/${padId}`).expect(200);
+    const socket = await common.connect(res);
+    try {
+      const {type, data: clientVars} = await common.handshake(socket, padId);
+      assert.equal(type, 'CLIENT_VARS');
+
+      const collabVars = clientVars.collab_client_vars;
+
+      // The rev in CLIENT_VARS must correspond to the initialAttributedText
+      assert.equal(typeof collabVars.rev, 'number');
+
+      // Verify the text from initialAttributedText matches the pad text
+      const iatText = collabVars.initialAttributedText.text;
+      assert.equal(iatText, expectedText,
+        `initialAttributedText.text doesn't match pad text at rev ${collabVars.rev}`);
Evidence
The test asserts rev is a number, but it does not validate that the attributed text matches the pad
state at that specific revision. The Pad API provides getInternalRevisionAText(rev), which can be
used to fetch the AText for a given revision and assert true rev/text consistency.

src/tests/backend/specs/clientvar_rev_consistency.ts[23-55]
src/node/db/Pad.ts[209-222]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The regression test claims to verify that `collabVars.rev` matches `initialAttributedText`, but it only checks `initialAttributedText.text === pad.text()`. This does not assert the core invariant (text corresponds to the advertised revision) and can miss the exact class of bug being guarded.
### Issue Context
`Pad.getInternalRevisionAText(rev)` exists and can compute the AText for an arbitrary revision.
### Fix Focus Areas
- src/tests/backend/specs/clientvar_rev_consistency.ts[23-55]
- src/node/db/Pad.ts[209-222]
### Proposed fix
In the test, replace (or supplement) the `pad.text()` comparison with a revision-specific comparison:
- After receiving `CLIENT_VARS`, fetch `const atextAtRev = await pad.getInternalRevisionAText(collabVars.rev);`
- Assert `atextAtRev.text === collabVars.initialAttributedText.text`
Optionally, to better exercise the original race, introduce a concurrent edit while the handshake is in progress (e.g., start `handshake()` but do not await it immediately, perform a `pad.setText()` or append revision, then await the handshake) and still assert consistency using `getInternalRevisionAText(collabVars.rev)` (do not assert it equals the latest head text in the presence of concurrency).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

JohnMcLear added a commit that referenced this pull request Apr 7, 2026
Addresses Qodo review items 1, 2, 5 from
https://github.com/ether/etherpad-lite/issues/comments/4194702740 :

- Concern 1 (no loadTesting reproduction test): the suite now toggles
  settings.loadTest = true in before(), restores in after(). The
  middle test also pre-populates the pad with 20 revisions before
  connecting so we genuinely exercise a busy/loaded pad rather than a
  fresh one.

- Concern 2 (no CLIENT_VARS / NEW_CHANGES delay test): the slow
  clientVars hook in the middle test now has explicit setTimeout
  delays before AND after the mid-handshake edits, so the race window
  between atext snapshot and CLIENT_VARS send is observably wide
  rather than relying on async scheduling alone. The test also
  collects post-handshake messages and asserts a NEW_CHANGES catch-up
  arrives when the pad advanced past the advertised rev.

- Concern 5 (test doesn't validate rev): both rev-consistency tests
  use pad.getInternalRevisionAText(advertisedRev) and assert text and
  attribs match, not just `pad.text() === clientVars.text`.

Concerns 3 (connect can miss revisions) and 4 (NaN timeDelta) were
already addressed in earlier commits on this branch via the catch-up
updatePadClients() call and the sessionInfo.time initialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
JohnMcLear and others added 6 commits April 7, 2026 18:12
…d apply

When constructing CLIENT_VARS, pad.atext was captured at one point but
pad.getHeadRevisionNumber() was called later. If concurrent edits
advanced the revision between these two reads, the client received
initialAttributedText from rev N but rev=N+3, causing "mismatched apply"
errors when the next changeset arrived (expecting rev N+3 text).

Now captures headRev at the same time as atext and uses the captured
value consistently in CLIENT_VARS and sessionInfo.

Fixes #4040

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
During handleClientReady(), the server awaits the clientVars hook before
socket.join(). Any revisions appended during that await window are
broadcast to existing room members but the connecting socket misses them.
Call updatePadClients(pad) after joining to flush any such revisions.

Also adds a regression test that injects a slow clientVars hook and
verifies the connecting client receives catch-up changesets for edits
that occurred during the hook await window.

Fixes #4040

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Listen for messages during handshake to avoid missing NEW_CHANGES that
arrive before the explicit waitForSocketEvent listener is attached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The catch-up updatePadClients() call introduced in this PR could send
NEW_CHANGES with timeDelta=NaN because sessionInfo.time was never set
for new sessions. NaN poisons the client-side broadcast/timeslider
currentTime tracking.

Initialize sessionInfo.time to the timestamp of the snapshot revision
before the catch-up flush, with a fallback to Date.now() if the
revision date is unavailable.

Also strengthens the regression tests:
- Validate that initialAttributedText matches the pad AText at the
  EXACT advertised rev (not just the latest pad text), using
  pad.getInternalRevisionAText(rev).
- Add a load test that hammers the pad with concurrent edits while
  multiple clients connect, asserting CLIENT_VARS consistency under
  the exact race condition the fix is targeting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous load test ran 'while (!stopLoad) await pad.setText(...)'
in the background while the test connected clients. This saturated
ueberDB's write queue and on shutdown the queued writes never drained,
hanging the mocha process for the full 6h GitHub Actions job timeout.

Replace it with a bounded approach: a clientVars hook lands 3 edits
mid-handshake (deterministic, no background loop, no shutdown hang).
Still exercises the exact race the fix targets — an edit advancing
the rev after the atext snapshot but before CLIENT_VARS is sent —
and asserts AText / rev consistency via getInternalRevisionAText.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses Qodo review items 1, 2, 5 from
https://github.com/ether/etherpad-lite/issues/comments/4194702740 :

- Concern 1 (no loadTesting reproduction test): the suite now toggles
  settings.loadTest = true in before(), restores in after(). The
  middle test also pre-populates the pad with 20 revisions before
  connecting so we genuinely exercise a busy/loaded pad rather than a
  fresh one.

- Concern 2 (no CLIENT_VARS / NEW_CHANGES delay test): the slow
  clientVars hook in the middle test now has explicit setTimeout
  delays before AND after the mid-handshake edits, so the race window
  between atext snapshot and CLIENT_VARS send is observably wide
  rather than relying on async scheduling alone. The test also
  collects post-handshake messages and asserts a NEW_CHANGES catch-up
  arrives when the pad advanced past the advertised rev.

- Concern 5 (test doesn't validate rev): both rev-consistency tests
  use pad.getInternalRevisionAText(advertisedRev) and assert text and
  attribs match, not just `pad.text() === clientVars.text`.

Concerns 3 (connect can miss revisions) and 4 (NaN timeDelta) were
already addressed in earlier commits on this branch via the catch-up
updatePadClients() call and the sessionInfo.time initialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JohnMcLear JohnMcLear force-pushed the fix/mismatched-apply-race-4040 branch from 61e3748 to 6e44d98 Compare April 7, 2026 17:13
@JohnMcLear JohnMcLear merged commit 31e0a61 into develop Apr 7, 2026
40 checks passed
@JohnMcLear JohnMcLear deleted the fix/mismatched-apply-race-4040 branch April 7, 2026 17:30
@JohnMcLear
Copy link
Copy Markdown
Member Author

I have some concerns w/ this PR RE Timings etc. but it's the best I can get it so far...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Applied attributes can cause apply mismatched. Needs load test coverage to replicate.

1 participant