From 9551da238375fbe39bc014b433e6d5e399739bc5 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 5 May 2026 12:50:15 -0600 Subject: [PATCH 1/4] Backport compaction processing fix that can cause user YDocs to diverge --- .../class-wp-http-polling-sync-server.php | 11 ++++-- .../tests/rest-api/rest-sync-server.php | 34 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php index a90821ab78d3e..dd2518b26302b 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -498,9 +498,14 @@ private function process_sync_update( string $room, int $client_id, int $cursor, return $this->add_update( $room, $client_id, $type, $data ); } - // Reaching this point means there's a newer compaction, so we can - // silently ignore this one. - return true; + /* + * A newer compaction already advanced the cursor, but we + * can not safely drop an update. The incoming bytes still encode + * operations other clients may not have seen, so store them as a + * regular update. Y.applyUpdateV2 merges state-as-update blobs + * idempotently, so overlap with the existing compaction is safe. + */ + return $this->add_update( $room, $client_id, self::UPDATE_TYPE_UPDATE, $data ); case self::UPDATE_TYPE_SYNC_STEP1: case self::UPDATE_TYPE_SYNC_STEP2: diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 7ded16bd3b033..abbf130deb6b3 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -936,7 +936,7 @@ public function test_sync_should_compact_is_false_for_non_compactor() { $this->assertFalse( $data['rooms'][0]['should_compact'] ); } - public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists() { + public function test_sync_stale_compaction_is_stored_as_update_when_newer_compaction_exists() { wp_set_current_user( self::$editor_id ); $room = $this->get_post_room(); @@ -962,13 +962,16 @@ public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists $this->dispatch_sync( array( - $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ), + $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ) ) ); - // Client 3 sends a stale compaction at cursor 0. The server should find - // client 2's compaction in the updates after cursor 0 and silently discard - // this one. + // Client 3 sends a stale compaction at cursor 0 (mirroring two offline + // clients that reconnect from the same baseline cursor). The server + // cannot run remove_updates_before_cursor because client 2 has already + // advanced the frontier, but the bytes must still be stored as a + // regular update so client 3's operations can propagate to other + // clients via Yjs state-as-update merging. $stale_compaction = array( 'type' => 'compaction', 'data' => 'c3RhbGU=', @@ -981,16 +984,29 @@ public function test_sync_stale_compaction_succeeds_when_newer_compaction_exists $this->assertSame( 200, $response->get_status() ); - // Verify the newer compaction is preserved and the stale one was not stored. - $response = $this->dispatch_sync( + // Verify the newer compaction is preserved AND the stale compaction's + // bytes were persisted (now as type=update so subsequent compactions + // don't trip the has_newer_compaction check). + $response = $this->dispatch_sync( array( $this->build_room( $room, 4, 0, array( 'user' => 'c4' ) ), ) ); - $update_data = wp_list_pluck( $response->get_data()['rooms'][0]['updates'], 'data' ); + $updates = $response->get_data()['rooms'][0]['updates']; + $update_data = wp_list_pluck( $updates, 'data' ); $this->assertContains( 'Y29tcGFjdGVk', $update_data, 'The newer compaction should be preserved.' ); - $this->assertNotContains( 'c3RhbGU=', $update_data, 'The stale compaction should not be stored.' ); + $this->assertContains( 'c3RhbGU=', $update_data, 'The stale compaction bytes should be stored so client 3\'s operations propagate.' ); + + $stale_entry = null; + foreach ( $updates as $entry ) { + if ( 'c3RhbGU=' === $entry['data'] ) { + $stale_entry = $entry; + break; + } + } + $this->assertNotNull( $stale_entry, 'The stale compaction entry should be present in the room.' ); + $this->assertSame( 'update', $stale_entry['type'], 'The stale compaction should be stored as type=update, not type=compaction.' ); } /* From d7e81bef800af8bd0f9d25175c9c7b828e11ed53 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 5 May 2026 13:18:49 -0600 Subject: [PATCH 2/4] Fix missing comma --- tests/phpunit/tests/rest-api/rest-sync-server.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index abbf130deb6b3..e62cf107cf522 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -962,7 +962,7 @@ public function test_sync_stale_compaction_is_stored_as_update_when_newer_compac $this->dispatch_sync( array( - $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ) + $this->build_room( $room, 2, $end_cursor, array( 'user' => 'c2' ), array( $compaction ) ), ) ); From 0b067a565e7f2ef05832ad38afc712b3808f373b Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 6 May 2026 09:40:17 +1000 Subject: [PATCH 3/4] alingment --- .../class-wp-http-polling-sync-server.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php index dd2518b26302b..5e369d9f77f0f 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -499,12 +499,12 @@ private function process_sync_update( string $room, int $client_id, int $cursor, } /* - * A newer compaction already advanced the cursor, but we - * can not safely drop an update. The incoming bytes still encode - * operations other clients may not have seen, so store them as a - * regular update. Y.applyUpdateV2 merges state-as-update blobs - * idempotently, so overlap with the existing compaction is safe. - */ + * A newer compaction already advanced the cursor, but we + * can not safely drop an update. The incoming bytes still encode + * operations other clients may not have seen, so store them as a + * regular update. Y.applyUpdateV2 merges state-as-update blobs + * idempotently, so overlap with the existing compaction is safe. + */ return $this->add_update( $room, $client_id, self::UPDATE_TYPE_UPDATE, $data ); case self::UPDATE_TYPE_SYNC_STEP1: From 1f4693d3f05e6e5234f379590c09c6c4490e8001 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 6 May 2026 09:46:54 +1000 Subject: [PATCH 4/4] CS: Multiline comment format. --- .../tests/rest-api/rest-sync-server.php | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index e62cf107cf522..f10246b7117de 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -966,12 +966,14 @@ public function test_sync_stale_compaction_is_stored_as_update_when_newer_compac ) ); - // Client 3 sends a stale compaction at cursor 0 (mirroring two offline - // clients that reconnect from the same baseline cursor). The server - // cannot run remove_updates_before_cursor because client 2 has already - // advanced the frontier, but the bytes must still be stored as a - // regular update so client 3's operations can propagate to other - // clients via Yjs state-as-update merging. + /* + * Client 3 sends a stale compaction at cursor 0 (mirroring two offline + * clients that reconnect from the same baseline cursor). The server + * cannot run remove_updates_before_cursor because client 2 has already + * advanced the frontier, but the bytes must still be stored as a + * regular update so client 3's operations can propagate to other + * clients via Yjs state-as-update merging. + */ $stale_compaction = array( 'type' => 'compaction', 'data' => 'c3RhbGU=', @@ -984,9 +986,11 @@ public function test_sync_stale_compaction_is_stored_as_update_when_newer_compac $this->assertSame( 200, $response->get_status() ); - // Verify the newer compaction is preserved AND the stale compaction's - // bytes were persisted (now as type=update so subsequent compactions - // don't trip the has_newer_compaction check). + /* + * Verify the newer compaction is preserved AND the stale compaction's + * bytes were persisted (now as type=update so subsequent compactions + * don't trip the has_newer_compaction check). + */ $response = $this->dispatch_sync( array( $this->build_room( $room, 4, 0, array( 'user' => 'c4' ) ),