Skip to content

Commit 732bcf5

Browse files
ingeniumedingeniumedchriszarate
authored andcommitted
RTC: Add a limit for the default provider (#76437)
* Pause updates when update exceeds provider limit * Pause all new collaborators from joining * Simplify the code so the lowest client ID pauses its updates and rest all are removed * Add tests * Use the enteredAt timestamp * Remove the debug bieng true * Attempt to show the post locked modal flow when a document is too large * Move update size check to onDocUpdate and update post-locked-modal * Rename provider-limit-exceeded to document-size-limit-exceeded * Cleanup * Ensure that the status is optional so it catches null syncConnectionStatus * Add a new e2e test * Correct the name * Attempting to see if marking a test as slow will work here * Re-do the test to avoid the timeout issues * Attempt to fix the e2e tests * Simplify / fix error types * Correct the error code in the post locked modal and fix the e2e test --------- Co-authored-by: ingeniumed <ingeniumed@git.wordpress.org> Co-authored-by: chriszarate <czarate@git.wordpress.org>
1 parent c8cd847 commit 732bcf5

File tree

13 files changed

+410
-46
lines changed

13 files changed

+410
-46
lines changed

‎packages/core-data/src/reducer.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createUndoManager } from '@wordpress/undo-manager';
1616
import { ifMatchingAction, replaceAction } from './utils';
1717
import { reducer as queriedDataReducer } from './queried-data';
1818
import { rootEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities';
19+
import { ConnectionErrorCode } from './sync';
1920

2021
/** @typedef {import('./types').AnyFunction} AnyFunction */
2122

@@ -703,6 +704,16 @@ export function collaborationSupported( state = true, action ) {
703704
switch ( action.type ) {
704705
case 'SET_COLLABORATION_SUPPORTED':
705706
return action.supported;
707+
708+
case 'SET_SYNC_CONNECTION_STATUS':
709+
if (
710+
ConnectionErrorCode.DOCUMENT_SIZE_LIMIT_EXCEEDED ===
711+
action.status?.error?.code
712+
) {
713+
return false;
714+
}
715+
716+
return state;
706717
}
707718
return state;
708719
}

‎packages/core-data/src/sync.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { unlock } from './lock-unlock';
1313

1414
const {
15+
ConnectionErrorCode,
1516
createSyncManager,
1617
Delta,
1718
CRDT_DOC_META_PERSISTENCE_KEY,
@@ -22,6 +23,7 @@ const {
2223
} = unlock( syncPrivateApis );
2324

2425
export {
26+
ConnectionErrorCode,
2527
Delta,
2628
CRDT_DOC_META_PERSISTENCE_KEY,
2729
CRDT_RECORD_MAP_KEY,

‎packages/editor/src/components/post-locked-modal/index.js‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,39 @@ import { addAction, removeAction } from '@wordpress/hooks';
1616
import { useInstanceId } from '@wordpress/compose';
1717
import { store as coreStore } from '@wordpress/core-data';
1818
import { unlock } from '../../lock-unlock';
19+
import { DOCUMENT_SIZE_LIMIT_EXCEEDED } from '../../utils/sync-error-messages';
1920

2021
/**
2122
* Internal dependencies
2223
*/
2324
import { store as editorStore } from '../../store';
2425

2526
function CollaborationContext() {
26-
const isCollaborationSupported = useSelect( ( select ) => {
27-
return unlock( select( coreStore ) ).isCollaborationSupported();
28-
}, [] );
27+
const { isCollaborationSupported, syncConnectionStatus } = useSelect(
28+
( select ) => {
29+
const selectors = unlock( select( coreStore ) );
30+
return {
31+
isCollaborationSupported: selectors.isCollaborationSupported(),
32+
syncConnectionStatus: selectors.getSyncConnectionStatus(),
33+
};
34+
},
35+
[]
36+
);
2937

3038
if ( isCollaborationSupported ) {
3139
return null;
3240
}
3341

42+
if ( DOCUMENT_SIZE_LIMIT_EXCEEDED === syncConnectionStatus?.error?.code ) {
43+
return (
44+
<p>
45+
{ __(
46+
'Because this post is too large for real-time collaboration, only one person can edit at a time.'
47+
) }
48+
</p>
49+
);
50+
}
51+
3452
return (
3553
<p>
3654
{ __(

‎packages/editor/src/components/sync-connection-modal/index.js‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,24 @@ const DISCONNECTED_DEBOUNCE_MS = 2000;
4646
* @return {Element|null} The modal component or null if not disconnected.
4747
*/
4848
export function SyncConnectionModal() {
49-
const { connectionState, postType } = useSelect( ( selectFn ) => {
50-
const currentPostType = selectFn( editorStore ).getCurrentPostType();
51-
return {
52-
connectionState:
53-
selectFn( coreDataStore ).getSyncConnectionStatus() || null,
54-
postType: currentPostType
55-
? selectFn( coreDataStore ).getPostType( currentPostType )
56-
: null,
57-
};
58-
}, [] );
49+
const { connectionState, isCollaborationEnabled, postType } = useSelect(
50+
( selectFn ) => {
51+
const currentPostType =
52+
selectFn( editorStore ).getCurrentPostType();
53+
return {
54+
connectionState:
55+
selectFn( coreDataStore ).getSyncConnectionStatus() || null,
56+
isCollaborationEnabled:
57+
selectFn(
58+
editorStore
59+
).isCollaborationEnabledForCurrentPost(),
60+
postType: currentPostType
61+
? selectFn( coreDataStore ).getPostType( currentPostType )
62+
: null,
63+
};
64+
},
65+
[]
66+
);
5967

6068
const { secondsRemaining, markRetrying } = useRetryCountdown(
6169
connectionState?.retryInMs,
@@ -115,7 +123,7 @@ export function SyncConnectionModal() {
115123
};
116124
}, [ connectionStatus, connectionErrorCode ] );
117125

118-
if ( ! syncConnectionMessage ) {
126+
if ( ! syncConnectionMessage || ! isCollaborationEnabled ) {
119127
return null;
120128
}
121129

‎packages/editor/src/utils/sync-error-messages.js‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,45 @@
33
*/
44
import { __ } from '@wordpress/i18n';
55

6+
// These error codes are defined in the sync package:
7+
// packages/sync/src/errors.ts
8+
export const AUTHENTICATION_FAILED = 'authentication-failed';
9+
export const CONNECTION_EXPIRED = 'connection-expired';
10+
export const CONNECTION_LIMIT_EXCEEDED = 'connection-limit-exceeded';
11+
export const DOCUMENT_SIZE_LIMIT_EXCEEDED = 'document-size-limit-exceeded';
12+
export const UNKNOWN_ERROR = 'unknown-error';
13+
614
/**
715
* Default error messages for known error codes.
816
*/
917
const ERROR_MESSAGES = {
10-
'authentication-failed': {
18+
[ AUTHENTICATION_FAILED ]: {
1119
title: __( 'Unable to connect' ),
1220
description: __(
1321
"Real-time collaboration couldn't verify your permissions. " +
1422
'Check that you have access to edit this post, or contact your site administrator.'
1523
),
1624
canRetry: false,
1725
},
18-
'connection-expired': {
26+
[ CONNECTION_EXPIRED ]: {
1927
title: __( 'Connection expired' ),
2028
description: __(
2129
'Your connection to real-time collaboration has timed out. ' +
2230
'Editing is paused to prevent conflicts with other editors.'
2331
),
2432
canRetry: true,
2533
},
26-
'connection-limit-exceeded': {
34+
[ CONNECTION_LIMIT_EXCEEDED ]: {
2735
title: __( 'Too many editors connected' ),
2836
description: __(
2937
'Real-time collaboration has reached its connection limit. ' +
3038
'Try again later or contact your site administrator.'
3139
),
3240
canRetry: true,
3341
},
34-
'unknown-error': {
42+
// DOCUMENT_SIZE_LIMIT_EXCEEDED is not included here because it results in
43+
// collaboration being disabled entirely.
44+
[ UNKNOWN_ERROR ]: {
3545
title: __( 'Connection lost' ),
3646
description: __(
3747
'The connection to real-time collaboration was interrupted. ' +
@@ -54,5 +64,5 @@ export function getSyncErrorMessages( error ) {
5464
return ERROR_MESSAGES[ error.code ];
5565
}
5666

57-
return ERROR_MESSAGES[ 'unknown-error' ];
67+
return ERROR_MESSAGES[ UNKNOWN_ERROR ];
5868
}

‎packages/editor/src/utils/test/sync-error-messages.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ describe( 'getSyncErrorMessages', () => {
88
'authentication-failed',
99
'connection-expired',
1010
'connection-limit-exceeded',
11+
'document-size-limit-exceeded',
1112
'unknown-error',
1213
] )(
1314
'should return title, description, and canRetry for "%s"',

‎packages/sync/src/errors.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export enum ConnectionErrorCode {
2+
AUTHENTICATION_FAILED = 'authentication-failed',
3+
CONNECTION_EXPIRED = 'connection-expired',
4+
CONNECTION_LIMIT_EXCEEDED = 'connection-limit-exceeded',
5+
DOCUMENT_SIZE_LIMIT_EXCEEDED = 'document-size-limit-exceeded',
6+
UNKNOWN_ERROR = 'unknown-error',
7+
}
8+
9+
export class ConnectionError extends Error {
10+
constructor(
11+
public code: ConnectionErrorCode = ConnectionErrorCode.UNKNOWN_ERROR,
12+
message?: string
13+
) {
14+
super( message );
15+
this.name = 'ConnectionError';
16+
}
17+
}

‎packages/sync/src/private-apis.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
LOCAL_EDITOR_ORIGIN,
88
LOCAL_UNDO_IGNORED_ORIGIN,
99
} from './config';
10+
import { ConnectionErrorCode } from './errors';
1011
import { lock } from './lock-unlock';
1112
import { createSyncManager } from './manager';
1213
import { pollingManager } from './providers/http-polling/polling-manager';
@@ -22,4 +23,5 @@ lock( privateApis, {
2223
LOCAL_EDITOR_ORIGIN,
2324
LOCAL_UNDO_IGNORED_ORIGIN,
2425
retrySyncConnection: () => pollingManager.retryNow(),
26+
ConnectionErrorCode,
2527
} );
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const MAX_ERROR_BACKOFF_IN_MS = 30 * 1000; // 30 seconds
2+
export const MAX_UPDATE_SIZE_IN_BYTES = 1 * 1024 * 1024; // 1 MB
3+
export const POLLING_INTERVAL_IN_MS = 1000; // 1 second or 1000 milliseconds
4+
export const POLLING_INTERVAL_WITH_COLLABORATORS_IN_MS = 250; // 250 milliseconds
5+
// Must be less than the server-side AWARENESS_TIMEOUT (30 s) to avoid
6+
// false disconnects when the tab is in the background.
7+
export const POLLING_INTERVAL_BACKGROUND_TAB_IN_MS = 25 * 1000; // 25 seconds

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ import * as syncProtocol from 'y-protocols/sync';
1111
/**
1212
* Internal dependencies
1313
*/
14+
import {
15+
MAX_ERROR_BACKOFF_IN_MS,
16+
MAX_UPDATE_SIZE_IN_BYTES,
17+
POLLING_INTERVAL_IN_MS,
18+
POLLING_INTERVAL_WITH_COLLABORATORS_IN_MS,
19+
POLLING_INTERVAL_BACKGROUND_TAB_IN_MS,
20+
} from './config';
21+
import { ConnectionError, ConnectionErrorCode } from '../../errors';
1422
import type { ConnectionStatus } from '../../types';
1523
import {
1624
type AwarenessState,
@@ -28,12 +36,6 @@ import {
2836
postSyncUpdateNonBlocking,
2937
} from './utils';
3038

31-
const POLLING_INTERVAL_IN_MS = 1000; // 1 second or 1000 milliseconds
32-
const POLLING_INTERVAL_WITH_COLLABORATORS_IN_MS = 250; // 250 milliseconds
33-
// Must be less than the server-side AWARENESS_TIMEOUT (30 s) to avoid
34-
// false disconnects when the tab is in the background.
35-
const POLLING_INTERVAL_BACKGROUND_TAB_IN_MS = 25 * 1000; // 25 seconds
36-
const MAX_ERROR_BACKOFF_IN_MS = 30 * 1000; // 30 seconds
3739
const POLLING_MANAGER_ORIGIN = 'polling-manager';
3840

3941
type LogFunction = ( message: string, debug?: object ) => void;
@@ -146,7 +148,9 @@ function processAwarenessUpdate(
146148

147149
// Removed clients are missing from the server state.
148150
const removed = new Set< number >(
149-
currentStates.keys().filter( ( clientId ) => ! state[ clientId ] )
151+
Array.from( currentStates.keys() ).filter(
152+
( clientId ) => ! state[ clientId ]
153+
)
150154
);
151155

152156
Object.entries( state ).forEach( ( [ clientIdString, awarenessState ] ) => {
@@ -459,6 +463,7 @@ function poll(): void {
459463
// Start polling.
460464
void start();
461465
}
466+
462467
function registerRoom( {
463468
room,
464469
doc,
@@ -483,6 +488,29 @@ function registerRoom( {
483488
return;
484489
}
485490

491+
if ( update.byteLength > MAX_UPDATE_SIZE_IN_BYTES ) {
492+
const state = roomStates.get( room );
493+
if ( ! state ) {
494+
return;
495+
}
496+
497+
state.log( 'Document size limit exceeded', {
498+
maxUpdateSizeInBytes: MAX_UPDATE_SIZE_IN_BYTES,
499+
updateSizeInBytes: update.byteLength,
500+
} );
501+
502+
state.onStatusChange( {
503+
status: 'disconnected',
504+
error: new ConnectionError(
505+
ConnectionErrorCode.DOCUMENT_SIZE_LIMIT_EXCEEDED,
506+
'Document size limit exceeded'
507+
),
508+
} );
509+
510+
// This is an unrecoverable error. Unregister the room to prevent syncing.
511+
unregisterRoom( room );
512+
}
513+
486514
// Tag local document changes as 'update' type.
487515
updateQueue.add( createSyncUpdate( update, SyncUpdateType.UPDATE ) );
488516
}

0 commit comments

Comments
 (0)