diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index 39e490c89..fcfed3889 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,197 +1,197 @@ // @flow import { createSelector } from 'reselect'; import { currentCalendarQuery } from './nav-selectors.js'; import { serverEntryInfo, serverEntryInfosObject, filterRawEntryInfosByCalendarQuery, } from '../shared/entry-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { SignedIdentityKeysBlob } from '../types/crypto-types.js'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types.js'; import type { AppState } from '../types/redux-types.js'; import type { ClientReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, type ClientServerRequest, type ClientClientResponse, } from '../types/request-types.js'; import type { SessionState } from '../types/session-types.js'; import type { OneTimeKeyGenerator } from '../types/socket-types.js'; import type { RawThreadInfo } from '../types/thread-types.js'; import type { CurrentUserInfo, UserInfos } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; import { minimumOneTimeKeysRequired } from '../utils/crypto-utils.js'; import { values, hash } from '../utils/objects.js'; const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.reportStore.queuedReports, ( mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => mainQueuedReports, ); const getClientResponsesSelector: ( state: AppState, ) => ( calendarActive: boolean, oneTimeKeyGenerator: ?OneTimeKeyGenerator, - getSignedIdentityKeysBlob: ?() => SignedIdentityKeysBlob, + getSignedIdentityKeysBlob: ?() => Promise, serverRequests: $ReadOnlyArray, -) => $ReadOnlyArray = createSelector( +) => Promise<$ReadOnlyArray> = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.entryStore.entryInfos, (state: AppState) => state.userStore.userInfos, (state: AppState) => state.currentUserInfo, currentCalendarQuery, ( threadInfos: { +[id: string]: RawThreadInfo }, entryInfos: { +[id: string]: RawEntryInfo }, userInfos: UserInfos, currentUserInfo: ?CurrentUserInfo, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => - ( + async ( calendarActive: boolean, oneTimeKeyGenerator: ?OneTimeKeyGenerator, - getSignedIdentityKeysBlob: ?() => SignedIdentityKeysBlob, + getSignedIdentityKeysBlob: ?() => Promise, serverRequests: $ReadOnlyArray, - ): $ReadOnlyArray => { + ): Promise<$ReadOnlyArray> => { const clientResponses = []; const serverRequestedPlatformDetails = serverRequests.some( request => request.type === serverRequestTypes.PLATFORM_DETAILS, ); for (const serverRequest of serverRequests) { if ( serverRequest.type === serverRequestTypes.PLATFORM && !serverRequestedPlatformDetails ) { clientResponses.push({ type: serverRequestTypes.PLATFORM, platform: getConfig().platformDetails.platform, }); } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(entryInfos)), calendarQuery(calendarActive), ); const hashResults = {}; for (const key in serverRequest.hashesToCheck) { const expectedHashValue = serverRequest.hashesToCheck[key]; let hashValue; if (key === 'threadInfos') { hashValue = hash(threadInfos); } else if (key === 'entryInfos') { hashValue = hash(filteredEntryInfos); } else if (key === 'userInfos') { hashValue = hash(userInfos); } else if (key === 'currentUserInfo') { hashValue = hash(currentUserInfo); } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); hashValue = hash(threadInfos[threadID]); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); let rawEntryInfo = filteredEntryInfos[entryID]; if (rawEntryInfo) { rawEntryInfo = serverEntryInfo(rawEntryInfo); } hashValue = hash(rawEntryInfo); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); hashValue = hash(userInfos[userID]); } else { continue; } hashResults[key] = expectedHashValue === hashValue; } const { failUnmentioned } = serverRequest; if (failUnmentioned && failUnmentioned.threadInfos) { for (const threadID in threadInfos) { const key = `threadInfo|${threadID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.entryInfos) { for (const entryID in filteredEntryInfos) { const key = `entryInfo|${entryID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.userInfos) { for (const userID in userInfos) { const key = `userInfo|${userID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } clientResponses.push({ type: serverRequestTypes.CHECK_STATE, hashResults, }); } else if ( serverRequest.type === serverRequestTypes.MORE_ONE_TIME_KEYS && oneTimeKeyGenerator ) { const keys: string[] = []; for (let i = 0; i < minimumOneTimeKeysRequired; ++i) { keys.push(oneTimeKeyGenerator(i)); } clientResponses.push({ type: serverRequestTypes.MORE_ONE_TIME_KEYS, keys, }); } else if ( serverRequest.type === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB && getSignedIdentityKeysBlob ) { - const signedIdentityKeysBlob = getSignedIdentityKeysBlob(); + const signedIdentityKeysBlob = await getSignedIdentityKeysBlob(); clientResponses.push({ type: serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB, signedIdentityKeysBlob, }); } } return clientResponses; }, ); const sessionStateFuncSelector: ( state: AppState, ) => (calendarActive: boolean) => SessionState = createSelector( (state: AppState) => state.messageStore.currentAsOf, (state: AppState) => state.updatesCurrentAsOf, currentCalendarQuery, ( messagesCurrentAsOf: number, updatesCurrentAsOf: number, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => (calendarActive: boolean): SessionState => ({ calendarQuery: calendarQuery(calendarActive), messagesCurrentAsOf, updatesCurrentAsOf, watchedIDs: threadWatcher.getWatchedIDs(), }), ); export { queuedReports, getClientResponsesSelector, sessionStateFuncSelector }; diff --git a/lib/socket/request-response-handler.react.js b/lib/socket/request-response-handler.react.js index aeaa451d6..759b80dc8 100644 --- a/lib/socket/request-response-handler.react.js +++ b/lib/socket/request-response-handler.react.js @@ -1,148 +1,150 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { InflightRequests, SocketTimeout } from './inflight-requests.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { Dispatch } from '../types/redux-types.js'; import { processServerRequestsActionType, type ClientClientResponse, type ClientServerRequest, } from '../types/request-types.js'; import { type ClientRequestsServerSocketMessage, type ClientServerSocketMessage, clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionInfo, } from '../types/socket-types.js'; import { ServerError } from '../utils/errors.js'; import { useSelector } from '../utils/redux-utils.js'; type BaseProps = { +inflightRequests: ?InflightRequests, +sendMessage: (message: ClientSocketMessageWithoutID) => number, +addListener: (listener: SocketListener) => void, +removeListener: (listener: SocketListener) => void, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, - ) => $ReadOnlyArray, + ) => Promise<$ReadOnlyArray>, +currentCalendarQuery: () => CalendarQuery, }; type Props = { ...BaseProps, +connection: ConnectionInfo, +dispatch: Dispatch, }; class RequestResponseHandler extends React.PureComponent { componentDidMount() { this.props.addListener(this.onMessage); } componentWillUnmount() { this.props.removeListener(this.onMessage); } render() { return null; } onMessage = (message: ClientServerSocketMessage) => { if (message.type !== serverSocketMessageTypes.REQUESTS) { return; } const { serverRequests } = message.payload; if (serverRequests.length === 0) { return; } const calendarQuery = this.props.currentCalendarQuery(); this.props.dispatch({ type: processServerRequestsActionType, payload: { serverRequests, calendarQuery, }, }); if (this.props.inflightRequests) { - const clientResponses = this.props.getClientResponses(serverRequests); - this.sendAndHandleClientResponsesToServerRequests(clientResponses); + const clientResponsesPromise = + this.props.getClientResponses(serverRequests); + this.sendAndHandleClientResponsesToServerRequests(clientResponsesPromise); } }; sendClientResponses( clientResponses: $ReadOnlyArray, ): Promise { const { inflightRequests } = this.props; invariant( inflightRequests, 'inflightRequests falsey inside sendClientResponses', ); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.RESPONSES, payload: { clientResponses }, }); return inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.REQUESTS, ); } - sendAndHandleClientResponsesToServerRequests( - clientResponses: $ReadOnlyArray, + async sendAndHandleClientResponsesToServerRequests( + clientResponsesPromise: Promise<$ReadOnlyArray>, ) { + const clientResponses = await clientResponsesPromise; if (clientResponses.length === 0) { return; } const promise = this.sendClientResponses(clientResponses); this.handleClientResponsesToServerRequests(promise, clientResponses); } async handleClientResponsesToServerRequests( promise: Promise, clientResponses: $ReadOnlyArray, retriesLeft: number = 1, ): Promise { try { await promise; } catch (e) { console.log(e); if ( !(e instanceof SocketTimeout) && (!(e instanceof ServerError) || e.message === 'unknown_error') && retriesLeft > 0 && this.props.connection.status === 'connected' && this.props.inflightRequests ) { // We'll only retry if the connection is healthy and the error is either // an unknown_error ServerError or something is neither a ServerError // nor a SocketTimeout. const newPromise = this.sendClientResponses(clientResponses); await this.handleClientResponsesToServerRequests( newPromise, clientResponses, retriesLeft - 1, ); } } } } const ConnectedRequestResponseHandler: React.ComponentType = React.memo(function ConnectedRequestResponseHandler(props) { const connection = useSelector(state => state.connection); const dispatch = useDispatch(); return ( ); }); export default ConnectedRequestResponseHandler; diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index a3109c919..99f0066f0 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,778 +1,778 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import ActivityHandler from './activity-handler.react.js'; import APIRequestHandler from './api-request-handler.react.js'; import CalendarQueryHandler from './calendar-query-handler.react.js'; import { InflightRequests, SocketTimeout, SocketOffline, } from './inflight-requests.js'; import MessageHandler from './message-handler.react.js'; import ReportHandler from './report-handler.react.js'; import RequestResponseHandler from './request-response-handler.react.js'; import UpdateHandler from './update-handler.react.js'; import { updateActivityActionTypes } from '../actions/activity-actions.js'; import { logOutActionTypes } from '../actions/user-actions.js'; import { unsupervisedBackgroundActionType } from '../reducers/lifecycle-state-reducer.js'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts.js'; import { logInActionSources, type LogOutResult, } from '../types/account-types.js'; import { isWebPlatform } from '../types/device-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { forcePolicyAcknowledgmentActionType } from '../types/policy-types.js'; import type { Dispatch } from '../types/redux-types.js'; import { serverRequestTypes, type ClientClientResponse, type ClientServerRequest, } from '../types/request-types.js'; import { type SessionState, type SessionIdentification, type PreRequestUserState, } from '../types/session-types.js'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ClientServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, type CommTransportLayer, } from '../types/socket-types.js'; import { actionLogger } from '../utils/action-logger.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; import { setNewSessionActionType, fetchNewCookieFromNativeCredentials, } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { ServerError } from '../utils/errors.js'; import { promiseAll } from '../utils/promises.js'; import sleep from '../utils/sleep.js'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = { +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, }; type Props = { ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => CommTransportLayer, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, - ) => $ReadOnlyArray, + ) => Promise<$ReadOnlyArray>, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, +noDataAfterPolicyAcknowledgment?: boolean, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: (preRequestUserState: PreRequestUserState) => Promise, +socketCrashLoopRecovery?: () => Promise, }; type State = { +inflightRequests: ?InflightRequests, }; class Socket extends React.PureComponent { state: State = { inflightRequests: null, }; socket: ?CommTransportLayer; nextClientMessageID: number = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent: boolean = isWebPlatform( getConfig().platformDetails.platform, ); reopenConnectionAfterClosing: boolean = false; invalidationRecoveryInProgress: boolean = false; initializedWithUserState: ?PreRequestUserState; failuresAfterPolicyAcknowledgment: number = 0; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || (!isWebPlatform(getConfig().platformDetails.platform) && (!this.props.cookie || !this.props.cookie.startsWith('user='))) ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus }, }); const socket = this.props.openSocket(); const openObject = {}; socket.onopen = () => { if (this.socket === socket) { this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected' }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting' }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting' }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect: $Call void, number> = _throttle( () => this.openSocket('reconnecting'), 2000, ); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render(): React.Node { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } sendMessageWithoutID: (message: ClientSocketMessageWithoutID) => number = message => { const id = this.nextClientMessageID++; // These conditions all do the same thing and the runtime checks are only // necessary for Flow if (message.type === clientSocketMessageTypes.INITIAL) { this.sendMessage( ({ ...message, id }: ClientInitialClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.sendMessage( ({ ...message, id }: ClientResponsesClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.PING) { this.sendMessage(({ ...message, id }: PingClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); } return id; }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } static messageFromEvent(event: MessageEvent): ?ClientServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } try { return JSON.parse(event.data); } catch (e) { console.log(e); return null; } } receiveMessage: (event: MessageEvent) => Promise = async event => { const message = Socket.messageFromEvent(event); if (!message) { return; } this.failuresAfterPolicyAcknowledgment = 0; const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } for (const listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } if (errorMessage === 'policies_not_accepted') { this.props.dispatch({ type: forcePolicyAcknowledgmentActionType, payload, }); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await fetchNewCookieFromNativeCredentials( this.props.dispatch, cookie, this.props.urlPrefix, logInActionSources.socketAuthErrorResolutionAttempt, ); if (!recoverySessionChange && sessionChange) { // This should only happen in the cookieSources.BODY (native) case when // the resolution attempt failed const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, logInActionSource: logInActionSources.socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener: (listener: SocketListener) => void = listener => { this.listeners.add(listener); }; removeListener: (listener: SocketListener) => void = listener => { this.listeners.delete(listener); }; onClose: () => void = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const promises = {}; const clientResponses = []; if (!this.initialPlatformDetailsSent) { this.initialPlatformDetailsSent = true; clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); promises.activityUpdateMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); promises.stateSyncMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); const { stateSyncMessage, activityUpdateMessage } = await promiseAll( promises, ); if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: queuedActivityUpdates, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, logInActionSource: undefined, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket: (retriesLeft?: number) => Promise = async ( retriesLeft = 1, ) => { try { await this.sendInitialMessage(); } catch (e) { if (this.props.noDataAfterPolicyAcknowledgment) { this.failuresAfterPolicyAcknowledgment++; } else { this.failuresAfterPolicyAcknowledgment = 0; } if ( this.failuresAfterPolicyAcknowledgment >= 2 && this.props.socketCrashLoopRecovery ) { this.failuresAfterPolicyAcknowledgment = 0; try { await this.props.socketCrashLoopRecovery(); } catch (error) { console.log(error); this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } return; } console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse: (messageID: number, isLate: boolean) => void = ( messageID, isLate, ) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean = alreadyClosed => { // On native, sometimes the app is backgrounded without the proper // callbacks getting triggered. This leaves us in an incorrect state for // two reasons: // (1) The connection is still considered to be active, causing API // requests to be processed via socket and failing. // (2) We rely on flipping foreground state in Redux to detect activity // changes, and thus won't think we need to update activity. if ( this.props.connection.status !== 'connected' || !this.messageLastReceived || this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || (actionLogger.mostRecentActionTime && actionLogger.mostRecentActionTime + 3000 < Date.now()) ) { return false; } if (!alreadyClosed) { this.cleanUpServerTerminatedSocket(); } this.props.dispatch({ type: unsupervisedBackgroundActionType, payload: null, }); return true; }; } export default Socket; diff --git a/native/selectors/socket-selectors.js b/native/selectors/socket-selectors.js index e8262309c..7b28098f8 100644 --- a/native/selectors/socket-selectors.js +++ b/native/selectors/socket-selectors.js @@ -1,97 +1,97 @@ // @flow import { createSelector } from 'reselect'; import { getClientResponsesSelector, sessionStateFuncSelector, } from 'lib/selectors/socket-selectors.js'; import { createOpenSocketFunction } from 'lib/shared/socket-utils.js'; import type { SignedIdentityKeysBlob } from 'lib/types/crypto-types.js'; import type { ClientServerRequest, ClientClientResponse, } from 'lib/types/request-types.js'; import type { SessionIdentification, SessionState, } from 'lib/types/session-types.js'; import type { OneTimeKeyGenerator } from 'lib/types/socket-types.js'; import { calendarActiveSelector } from '../navigation/nav-selectors.js'; import type { AppState } from '../redux/state-types.js'; import type { NavPlusRedux } from '../types/selector-types.js'; const openSocketSelector: (state: AppState) => () => WebSocket = createSelector( (state: AppState) => state.urlPrefix, // We don't actually use the cookie in the socket open function, but we do use // it in the initial message, and when the cookie changes the socket needs to // be reopened. By including the cookie here, whenever the cookie changes this // function will change, which tells the Socket component to restart the // connection. (state: AppState) => state.cookie, createOpenSocketFunction, ); const sessionIdentificationSelector: ( state: AppState, ) => SessionIdentification = createSelector( (state: AppState) => state.cookie, (cookie: ?string): SessionIdentification => ({ cookie }), ); function oneTimeKeyGenerator(inc: number): string { // todo replace this hard code with something like // commCoreModule.generateOneTimeKeys() let str = Date.now().toString() + '_' + inc.toString() + '_'; while (str.length < 43) { str += Math.random().toString(36).substr(2, 5); } str = str.substr(0, 43); return str; } const nativeGetClientResponsesSelector: ( input: NavPlusRedux, ) => ( serverRequests: $ReadOnlyArray, -) => $ReadOnlyArray = createSelector( +) => Promise<$ReadOnlyArray> = createSelector( (input: NavPlusRedux) => getClientResponsesSelector(input.redux), (input: NavPlusRedux) => calendarActiveSelector(input.navContext), ( getClientResponsesFunc: ( calendarActive: boolean, oneTimeKeyGenerator: ?OneTimeKeyGenerator, - getSignedIdentityKeysBlob: ?() => SignedIdentityKeysBlob, + getSignedIdentityKeysBlob: ?() => Promise, serverRequests: $ReadOnlyArray, - ) => $ReadOnlyArray, + ) => Promise<$ReadOnlyArray>, calendarActive: boolean, ) => (serverRequests: $ReadOnlyArray) => getClientResponsesFunc( calendarActive, oneTimeKeyGenerator, null, serverRequests, ), ); const nativeSessionStateFuncSelector: ( input: NavPlusRedux, ) => () => SessionState = createSelector( (input: NavPlusRedux) => sessionStateFuncSelector(input.redux), (input: NavPlusRedux) => calendarActiveSelector(input.navContext), ( sessionStateFunc: (calendarActive: boolean) => SessionState, calendarActive: boolean, ) => () => sessionStateFunc(calendarActive), ); export { openSocketSelector, sessionIdentificationSelector, nativeGetClientResponsesSelector, nativeSessionStateFuncSelector, }; diff --git a/web/selectors/socket-selectors.js b/web/selectors/socket-selectors.js index fbc823ace..3d3ce5fcd 100644 --- a/web/selectors/socket-selectors.js +++ b/web/selectors/socket-selectors.js @@ -1,72 +1,72 @@ // @flow import { createSelector } from 'reselect'; import { getClientResponsesSelector, sessionStateFuncSelector, } from 'lib/selectors/socket-selectors.js'; import { createOpenSocketFunction } from 'lib/shared/socket-utils.js'; import type { SignedIdentityKeysBlob } from 'lib/types/crypto-types.js'; import type { ClientServerRequest, ClientClientResponse, } from 'lib/types/request-types.js'; import type { SessionIdentification, SessionState, } from 'lib/types/session-types.js'; import type { OneTimeKeyGenerator } from 'lib/types/socket-types.js'; import type { AppState } from '../redux/redux-setup.js'; const openSocketSelector: (state: AppState) => () => WebSocket = createSelector( (state: AppState) => state.baseHref, createOpenSocketFunction, ); const sessionIdentificationSelector: ( state: AppState, ) => SessionIdentification = createSelector( (state: AppState) => state.sessionID, (sessionID: ?string): SessionIdentification => ({ sessionID }), ); const webGetClientResponsesSelector: ( state: AppState, ) => ( serverRequests: $ReadOnlyArray, -) => $ReadOnlyArray = createSelector( +) => Promise<$ReadOnlyArray> = createSelector( getClientResponsesSelector, (state: AppState) => state.navInfo.tab === 'calendar', ( getClientResponsesFunc: ( calendarActive: boolean, oneTimeKeyGenerator: ?OneTimeKeyGenerator, - getSignedIdentityKeysBlob: ?() => SignedIdentityKeysBlob, + getSignedIdentityKeysBlob: ?() => Promise, serverRequests: $ReadOnlyArray, - ) => $ReadOnlyArray, + ) => Promise<$ReadOnlyArray>, calendarActive: boolean, ) => (serverRequests: $ReadOnlyArray) => getClientResponsesFunc(calendarActive, null, null, serverRequests), ); const webSessionStateFuncSelector: (state: AppState) => () => SessionState = createSelector( sessionStateFuncSelector, (state: AppState) => state.navInfo.tab === 'calendar', ( sessionStateFunc: (calendarActive: boolean) => SessionState, calendarActive: boolean, ) => () => sessionStateFunc(calendarActive), ); export { openSocketSelector, sessionIdentificationSelector, webGetClientResponsesSelector, webSessionStateFuncSelector, };