Skip to content

Commit 1642980

Browse files
authored
RTC: Fix "Connection Lost" dialog when too many entities are loaded (#77631)
* Rotate rooms > MAX_ROOMS_PER_REQUEST in polling manager * Add tests for rotateWindow, postSyncUpdate changes * Only emit status changes to rooms in the current request pool * Fix polling tests to expect a single room on first poll * Fix tests by removing status changes from unregistered rooms
1 parent 9d4577f commit 1642980

5 files changed

Lines changed: 434 additions & 17 deletions

File tree

packages/sync/src/providers/http-polling/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export const MANUAL_RETRY_INTERVAL_MS = 15000;
2626

2727
export const MAX_UPDATE_SIZE_IN_BYTES = 1 * 1024 * 1024; // 1 MB
2828

29+
// Corresponds with server-side
30+
// WP_HTTP_Polling_Sync_Server::MAX_ROOMS_PER_REQUEST.
31+
export const MAX_ROOMS_PER_REQUEST = 50;
32+
2933
export const POLLING_INTERVAL_IN_MS = applyFilters(
3034
'sync.pollingManager.pollingInterval',
3135
4000 // 4 seconds

packages/sync/src/providers/http-polling/polling-manager.ts

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
DEFAULT_CLIENT_LIMIT_PER_ROOM,
2121
ERROR_RETRY_DELAYS_SOLO_MS,
2222
ERROR_RETRY_DELAYS_WITH_COLLABORATORS_MS,
23+
MAX_ROOMS_PER_REQUEST,
2324
MAX_UPDATE_SIZE_IN_BYTES,
2425
POLLING_INTERVAL_IN_MS,
2526
POLLING_INTERVAL_WITH_COLLABORATORS_IN_MS,
@@ -44,6 +45,7 @@ import {
4445
intValueOrDefault,
4546
postSyncUpdate,
4647
postSyncUpdateNonBlocking,
48+
rotateWindow,
4749
} from './utils';
4850

4951
const POLLING_MANAGER_ORIGIN = 'polling-manager';
@@ -439,6 +441,12 @@ let isUnloadPending = false;
439441
let pollInterval = POLLING_INTERVAL_IN_MS;
440442
let pollingTimeoutId: ReturnType< typeof setTimeout > | null = null;
441443

444+
// When more rooms are registered than the server allows per request
445+
// (MAX_ROOMS_PER_REQUEST), the primary room is sent every poll and the
446+
// remaining "overflow" rooms are rotated across polls. This offset
447+
// points into the overflow list at the next room to include.
448+
let roomOverflowOffset = 0;
449+
442450
/**
443451
* Mark that a page unload has been requested. This fires on
444452
* `beforeunload` which happens before the browser aborts in-flight
@@ -468,7 +476,11 @@ function handlePageHide(): void {
468476
} )
469477
);
470478

471-
postSyncUpdateNonBlocking( { rooms } );
479+
for ( let i = 0; i < rooms.length; i += MAX_ROOMS_PER_REQUEST ) {
480+
postSyncUpdateNonBlocking( {
481+
rooms: rooms.slice( i, i + MAX_ROOMS_PER_REQUEST ),
482+
} );
483+
}
472484
}
473485

474486
/**
@@ -506,6 +518,49 @@ function handleVisibilityChange() {
506518
}
507519
}
508520

521+
/**
522+
* Select which rooms to include in the next sync request.
523+
*
524+
* The server caps requests at MAX_ROOMS_PER_REQUEST rooms. When fewer rooms are
525+
* registered than the cap, every room is included on every poll. When the cap
526+
* is exceeded, the primary room is sent on every poll (so the main document
527+
* stays fully synced) and the remaining overflow rooms are rotated across
528+
* successive polls so each one is included (at a reduced frequency).
529+
*
530+
* Rooms that are skipped on a given poll keep their queued updates; the updates
531+
* are drained on the next poll that includes them.
532+
*
533+
* @return The RoomStates to include in this request, in send order.
534+
*/
535+
function selectRoomsForRequest(): RoomState[] {
536+
const allRooms = Array.from( roomStates.values() );
537+
538+
// Fast path: everything fits in a single request.
539+
if ( allRooms.length <= MAX_ROOMS_PER_REQUEST ) {
540+
return allRooms;
541+
}
542+
543+
// Rotation path: pin the primary room to every request (if one exists)
544+
// and rotate the remaining overflow rooms across successive polls.
545+
const primaryRoom = allRooms.find( ( state ) => state.isPrimaryRoom );
546+
const overflowRooms = allRooms.filter( ( state ) => state !== primaryRoom );
547+
const overflowSlotsPerRequest =
548+
MAX_ROOMS_PER_REQUEST - ( primaryRoom ? 1 : 0 );
549+
550+
const { window: overflowSlice, nextOffset } = rotateWindow(
551+
overflowRooms,
552+
roomOverflowOffset,
553+
overflowSlotsPerRequest
554+
);
555+
roomOverflowOffset = nextOffset;
556+
557+
if ( primaryRoom ) {
558+
return [ primaryRoom, ...overflowSlice ];
559+
}
560+
561+
return overflowSlice;
562+
}
563+
509564
function poll(): void {
510565
isPolling = true;
511566
pollingTimeoutId = null;
@@ -521,34 +576,41 @@ function poll(): void {
521576
// cancels a beforeunload dialog.
522577
isUnloadPending = false;
523578

524-
// Emit 'connecting' status.
525-
roomStates.forEach( ( state ) => {
526-
state.onStatusChange( { status: 'connecting' } );
527-
} );
528-
529579
// Create a payload with all queued updates. We include rooms even if they
530580
// have no updates to ensure we receive any incoming updates. Note that we
531581
// withhold our own updates until we detect another collaborator using the
532582
// queue's pause / resume mechanism.
583+
const roomsInRequest = selectRoomsForRequest();
533584
const payload: SyncPayload = {
534-
rooms: Array.from( roomStates.entries() ).map(
535-
( [ room, state ] ) => ( {
536-
after: state.endCursor ?? 0,
537-
awareness: state.localAwarenessState,
538-
client_id: state.clientId,
539-
room,
540-
updates: state.updateQueue.get(),
541-
} )
542-
),
585+
rooms: roomsInRequest.map( ( state ) => ( {
586+
after: state.endCursor ?? 0,
587+
awareness: state.localAwarenessState,
588+
client_id: state.clientId,
589+
room: state.room,
590+
updates: state.updateQueue.get(),
591+
} ) ),
543592
};
544593

594+
// Emit 'connecting' status only for rooms in this request. Rooms
595+
// rotated out of this poll keep their prior status.
596+
roomsInRequest.forEach( ( state ) => {
597+
state.onStatusChange( { status: 'connecting' } );
598+
} );
599+
545600
try {
546601
const { rooms } = await postSyncUpdate( payload );
547602

548603
// Emit 'connected' status.
549604
consecutiveFailures = 0;
550605
isManualRetry = false;
551-
roomStates.forEach( ( state ) => {
606+
roomsInRequest.forEach( ( state ) => {
607+
// Skip rooms unregistered during the await (e.g. the
608+
// size-limit handler in onDocUpdate). Their terminal
609+
// status was already set by whatever unregistered them.
610+
if ( roomStates.get( state.room ) !== state ) {
611+
return;
612+
}
613+
552614
state.onStatusChange( { status: 'connected' } );
553615
} );
554616

@@ -714,7 +776,13 @@ function poll(): void {
714776
const backgroundRetriesFailed =
715777
consecutiveFailures > retrySchedule.length;
716778

717-
roomStates.forEach( ( state ) => {
779+
roomsInRequest.forEach( ( state ) => {
780+
// Skip rooms unregistered during the await so
781+
// their terminal status isn't overwritten.
782+
if ( roomStates.get( state.room ) !== state ) {
783+
return;
784+
}
785+
718786
state.onStatusChange( {
719787
status: 'disconnected',
720788
canManuallyRetry: true,
@@ -897,6 +965,7 @@ function unregisterRoom(
897965
areListenersRegistered = false;
898966
hasCheckedConnectionLimit = false;
899967
consecutiveFailures = 0;
968+
roomOverflowOffset = 0;
900969
}
901970
}
902971

packages/sync/src/providers/http-polling/test/polling-manager.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ jest.mock( '@wordpress/hooks', () => ( {
4545
jest.mock( '../config', () => ( {
4646
...( jest.requireActual( '../config' ) as object ),
4747
MAX_UPDATE_SIZE_IN_BYTES: 10,
48+
// Shrink the per-request room cap so rotation tests don't need 50+
49+
// registered rooms. Existing tests register at most 2 rooms and
50+
// stay well under this cap.
51+
MAX_ROOMS_PER_REQUEST: 10,
4852
} ) );
4953

5054
jest.mock( '../utils', () => ( {
@@ -1280,4 +1284,190 @@ describe( 'polling-manager', () => {
12801284
expect( mockPostSyncUpdate ).toHaveBeenCalledTimes( 3 );
12811285
} );
12821286
} );
1287+
1288+
describe( 'room overflow rotation', () => {
1289+
// The outer mock sets MAX_ROOMS_PER_REQUEST to 10. Tests in this
1290+
// block register a primary room plus additional "overflow" rooms
1291+
// to exercise the rotation behavior. With cap=10 and the primary
1292+
// pinned, each request carries 9 overflow slots.
1293+
//
1294+
// Note: the first registerRoom call triggers poll() synchronously,
1295+
// so the first poll's payload contains only the primary room.
1296+
// Overflow rooms registered in the same tick are picked up starting
1297+
// with the second poll, which is when rotation behavior kicks in.
1298+
1299+
function registerRoom( pollingMgr: PollingManager, room: string ) {
1300+
pollingMgr.registerRoom( {
1301+
room,
1302+
doc: createMockDoc( 1 ),
1303+
awareness: createMockAwareness(),
1304+
log: jest.fn(),
1305+
onStatusChange: jest.fn(),
1306+
onSync: jest.fn(),
1307+
} );
1308+
}
1309+
1310+
function registerPrimaryAndOverflow(
1311+
pollingMgr: PollingManager,
1312+
overflowCount: number
1313+
): string[] {
1314+
registerRoom( pollingMgr, 'primary' );
1315+
const overflowNames: string[] = [];
1316+
for ( let i = 1; i <= overflowCount; i++ ) {
1317+
const name = `o${ i }`;
1318+
overflowNames.push( name );
1319+
registerRoom( pollingMgr, name );
1320+
}
1321+
return overflowNames;
1322+
}
1323+
1324+
function getRoomNames( callIndex: number ): string[] {
1325+
const payload = mockPostSyncUpdate.mock.calls[ callIndex ][ 0 ] as {
1326+
rooms: { room: string }[];
1327+
};
1328+
return payload.rooms.map( ( r ) => r.room );
1329+
}
1330+
1331+
it( 'sends every room in a single request when the count is at or under the cap', async () => {
1332+
mockPostSyncUpdate.mockResolvedValue( { rooms: [] } );
1333+
1334+
// Primary + 9 overflow = 10 rooms, exactly at the cap.
1335+
const overflow = registerPrimaryAndOverflow( pollingManager, 9 );
1336+
1337+
await jest.advanceTimersByTimeAsync( 0 );
1338+
await jest.advanceTimersByTimeAsync( 4000 );
1339+
1340+
expect( mockPostSyncUpdate ).toHaveBeenCalledTimes( 2 );
1341+
1342+
// First poll fires synchronously with only the primary room.
1343+
expect( getRoomNames( 0 ) ).toEqual( [ 'primary' ] );
1344+
1345+
// Second poll includes every registered room in a single
1346+
// request (fast path since total rooms === cap).
1347+
expect( getRoomNames( 1 ) ).toEqual( [ 'primary', ...overflow ] );
1348+
} );
1349+
1350+
it( 'caps each request at MAX_ROOMS_PER_REQUEST and always includes the primary room', async () => {
1351+
mockPostSyncUpdate.mockResolvedValue( { rooms: [] } );
1352+
1353+
// Primary + 11 overflow = 12 rooms, over the cap of 10.
1354+
registerPrimaryAndOverflow( pollingManager, 11 );
1355+
1356+
await jest.advanceTimersByTimeAsync( 0 );
1357+
await jest.advanceTimersByTimeAsync( 4000 );
1358+
await jest.advanceTimersByTimeAsync( 4000 );
1359+
1360+
expect( mockPostSyncUpdate ).toHaveBeenCalledTimes( 3 );
1361+
1362+
// First poll: only the primary room was registered yet.
1363+
expect( getRoomNames( 0 ) ).toEqual( [ 'primary' ] );
1364+
1365+
// Subsequent polls cap at MAX_ROOMS_PER_REQUEST and pin primary.
1366+
for ( let i = 1; i < 3; i++ ) {
1367+
const names = getRoomNames( i );
1368+
expect( names ).toHaveLength( 10 );
1369+
expect( names[ 0 ] ).toBe( 'primary' );
1370+
}
1371+
} );
1372+
1373+
it( 'rotates overflow rooms across successive polls until all are covered', async () => {
1374+
mockPostSyncUpdate.mockResolvedValue( { rooms: [] } );
1375+
1376+
// Primary + 15 overflow = 16 rooms. Skipping the primary-only
1377+
// first poll, two subsequent rotation polls send 18 slots —
1378+
// enough to cover every overflow room at least once.
1379+
const overflow = registerPrimaryAndOverflow( pollingManager, 15 );
1380+
1381+
await jest.advanceTimersByTimeAsync( 0 );
1382+
await jest.advanceTimersByTimeAsync( 4000 );
1383+
await jest.advanceTimersByTimeAsync( 4000 );
1384+
1385+
const overflowSeen = new Set< string >();
1386+
// Skip poll 0 (primary only); inspect rotation polls.
1387+
for ( let i = 1; i < 3; i++ ) {
1388+
for ( const name of getRoomNames( i ) ) {
1389+
if ( name !== 'primary' ) {
1390+
overflowSeen.add( name );
1391+
}
1392+
}
1393+
}
1394+
1395+
expect( overflowSeen ).toEqual( new Set( overflow ) );
1396+
} );
1397+
1398+
it( 'advances the rotation window so successive polls send different overflow rooms', async () => {
1399+
mockPostSyncUpdate.mockResolvedValue( { rooms: [] } );
1400+
1401+
// Primary + 11 overflow rooms, 9 slots per request.
1402+
registerPrimaryAndOverflow( pollingManager, 11 );
1403+
1404+
await jest.advanceTimersByTimeAsync( 0 );
1405+
await jest.advanceTimersByTimeAsync( 4000 );
1406+
await jest.advanceTimersByTimeAsync( 4000 );
1407+
1408+
// Compare the two rotation polls (poll 0 is primary-only).
1409+
const first = getRoomNames( 1 ).slice( 1 );
1410+
const second = getRoomNames( 2 ).slice( 1 );
1411+
1412+
expect( first ).not.toEqual( second );
1413+
// Two rotation polls of 9 slots against 11 overflow rooms
1414+
// cover the entire set.
1415+
expect( new Set( [ ...first, ...second ] ).size ).toBe( 11 );
1416+
} );
1417+
1418+
it( 'advances the rotation window even when a poll fails', async () => {
1419+
// Primary + 11 overflow rooms, 9 slots per request.
1420+
registerPrimaryAndOverflow( pollingManager, 11 );
1421+
1422+
// Poll 1: primary only (fires synchronously at registration).
1423+
mockPostSyncUpdate.mockResolvedValueOnce( { rooms: [] } );
1424+
await jest.advanceTimersByTimeAsync( 0 );
1425+
expect( mockPostSyncUpdate ).toHaveBeenCalledTimes( 1 );
1426+
expect( getRoomNames( 0 ) ).toEqual( [ 'primary' ] );
1427+
1428+
// Poll 2 fails while sending primary + 9 overflow. The
1429+
// rotation offset should still advance past this window.
1430+
mockPostSyncUpdate.mockRejectedValueOnce( new Error( 'network' ) );
1431+
await jest.advanceTimersByTimeAsync( 4000 );
1432+
expect( mockPostSyncUpdate ).toHaveBeenCalledTimes( 2 );
1433+
1434+
const failedSent = getRoomNames( 1 );
1435+
expect( failedSent ).toHaveLength( 10 );
1436+
expect( failedSent[ 0 ] ).toBe( 'primary' );
1437+
1438+
// Poll 3 retries after the failure and should send a different
1439+
// overflow slice, proving the offset advanced despite the error.
1440+
mockPostSyncUpdate.mockResolvedValueOnce( { rooms: [] } );
1441+
await jest.advanceTimersByTimeAsync( 2000 );
1442+
expect( mockPostSyncUpdate ).toHaveBeenCalledTimes( 3 );
1443+
1444+
const retrySent = getRoomNames( 2 );
1445+
expect( retrySent ).toHaveLength( 10 );
1446+
expect( retrySent[ 0 ] ).toBe( 'primary' );
1447+
expect( retrySent ).not.toEqual( failedSent );
1448+
} );
1449+
1450+
it( 'chunks the page-hide disconnect beacon so each request stays under the cap', async () => {
1451+
mockPostSyncUpdate.mockResolvedValue( { rooms: [] } );
1452+
1453+
// 21 rooms at cap=10 => three beacons (10 + 10 + 1).
1454+
registerPrimaryAndOverflow( pollingManager, 20 );
1455+
1456+
// Flush the initial poll so the pagehide test observes
1457+
// postSyncUpdateNonBlocking calls from the page-hide handler only.
1458+
await jest.advanceTimersByTimeAsync( 0 );
1459+
mockPostSyncUpdateNonBlocking.mockClear();
1460+
1461+
window.dispatchEvent( new Event( 'pagehide' ) );
1462+
1463+
expect( mockPostSyncUpdateNonBlocking ).toHaveBeenCalledTimes( 3 );
1464+
1465+
const beaconsSent = mockPostSyncUpdateNonBlocking.mock.calls.map(
1466+
( call ) =>
1467+
( call[ 0 ] as { rooms: { room: string }[] } ).rooms.length
1468+
);
1469+
expect( beaconsSent.every( ( n ) => n <= 10 ) ).toBe( true );
1470+
expect( beaconsSent.reduce( ( a, b ) => a + b, 0 ) ).toBe( 21 );
1471+
} );
1472+
} );
12831473
} );

0 commit comments

Comments
 (0)