diff --git a/lib/socket/api-request-handler.react.js b/lib/socket/api-request-handler.react.js index 354b622b6..c111e669d 100644 --- a/lib/socket/api-request-handler.react.js +++ b/lib/socket/api-request-handler.react.js @@ -1,104 +1,104 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { InflightRequests } from './inflight-requests.js'; import { connectionSelector } from '../selectors/keyserver-selectors.js'; import type { APIRequest } from '../types/endpoints.js'; import { clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type ConnectionInfo, type APIResponseServerSocketMessage, } from '../types/socket-types.js'; import { registerActiveSocket } from '../utils/action-utils.js'; import { SocketOffline } from '../utils/errors.js'; import { useSelector } from '../utils/redux-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; type BaseProps = { +inflightRequests: ?InflightRequests, +sendMessage: (message: ClientSocketMessageWithoutID) => number, }; type Props = { ...BaseProps, +connection: ConnectionInfo, }; class APIRequestHandler extends React.PureComponent { - static isConnected(props: Props, request?: APIRequest) { + static isConnected(props: Props, request?: APIRequest): boolean { const { inflightRequests, connection } = props; if (!inflightRequests) { return false; } // This is a hack. We actually have a race condition between // ActivityHandler and Socket. Both of them respond to a backgrounding, but // we want ActivityHandler to go first. Once it sends its message, Socket // will wait for the response before shutting down. But if Socket starts // shutting down first, we'll have a problem. Note that this approach only // stops the race in fetchResponse below, and not in action-utils (which // happens earlier via the registerActiveSocket call below), but empirically // that hasn't been an issue. // The reason I didn't rewrite this to happen in a single component is // because I want to maintain separation of concerns. Upcoming React Hooks // will be a great way to rewrite them to be related but still separated. return ( connection.status === 'connected' || - (request && request.endpoint === 'update_activity') + request?.endpoint === 'update_activity' ); } - get registeredResponseFetcher() { + get registeredResponseFetcher(): ?(request: APIRequest) => Promise { return APIRequestHandler.isConnected(this.props) ? this.fetchResponse : null; } componentDidMount() { registerActiveSocket(this.registeredResponseFetcher); } componentWillUnmount() { registerActiveSocket(null); } componentDidUpdate(prevProps: Props) { const isConnected = APIRequestHandler.isConnected(this.props); const wasConnected = APIRequestHandler.isConnected(prevProps); if (isConnected !== wasConnected) { registerActiveSocket(this.registeredResponseFetcher); } } - render() { + render(): React.Node { return null; } - fetchResponse = async (request: APIRequest): Promise => { + fetchResponse = async (request: APIRequest): Promise => { if (!APIRequestHandler.isConnected(this.props, request)) { throw new SocketOffline('socket_offline'); } const { inflightRequests } = this.props; invariant(inflightRequests, 'inflightRequests falsey inside fetchResponse'); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.API_REQUEST, payload: request, }); const response = await inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.API_RESPONSE, ); return response.payload; }; } const ConnectedAPIRequestHandler: React.ComponentType = React.memo(function ConnectedAPIRequestHandler(props) { const connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); return ; }); export default ConnectedAPIRequestHandler; diff --git a/lib/socket/calendar-query-handler.react.js b/lib/socket/calendar-query-handler.react.js index 54a285bb5..95a0f962a 100644 --- a/lib/socket/calendar-query-handler.react.js +++ b/lib/socket/calendar-query-handler.react.js @@ -1,154 +1,154 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { updateCalendarQueryActionTypes, useUpdateCalendarQuery, } from '../actions/entry-actions.js'; import type { UpdateCalendarQueryInput } from '../actions/entry-actions.js'; import { connectionSelector } from '../selectors/keyserver-selectors.js'; import { timeUntilCalendarRangeExpiration } from '../selectors/nav-selectors.js'; import { useIsAppForegrounded } from '../shared/lifecycle-utils.js'; import type { CalendarQuery, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from '../types/entry-types.js'; import { type ConnectionInfo } from '../types/socket-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; type BaseProps = { +currentCalendarQuery: () => CalendarQuery, +frozen: boolean, }; type Props = { ...BaseProps, +connection: ConnectionInfo, +calendarQuery: CalendarQuery, +lastUserInteractionCalendar: number, +foreground: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( input: UpdateCalendarQueryInput, ) => Promise, }; class CalendarQueryHandler extends React.PureComponent { serverCalendarQuery: CalendarQuery; expirationTimeoutID: ?TimeoutID; constructor(props: Props) { super(props); this.serverCalendarQuery = this.props.calendarQuery; } componentDidMount() { if (this.props.connection.status === 'connected') { this.possiblyUpdateCalendarQuery(); } } componentDidUpdate(prevProps: Props) { const { calendarQuery } = this.props; if (this.props.connection.status !== 'connected') { if (!_isEqual(this.serverCalendarQuery)(calendarQuery)) { this.serverCalendarQuery = calendarQuery; } return; } if ( !_isEqual(this.serverCalendarQuery)(calendarQuery) && _isEqual(this.props.currentCalendarQuery())(calendarQuery) ) { this.serverCalendarQuery = calendarQuery; } const shouldUpdate = (this.isExpired || prevProps.connection.status !== 'connected' || this.props.currentCalendarQuery !== prevProps.currentCalendarQuery) && this.shouldUpdateCalendarQuery; if (shouldUpdate) { this.updateCalendarQuery(); } } - render() { + render(): React.Node { return null; } - get isExpired() { + get isExpired(): boolean { const timeUntilExpiration = timeUntilCalendarRangeExpiration( this.props.lastUserInteractionCalendar, ); return ( timeUntilExpiration !== null && timeUntilExpiration !== undefined && timeUntilExpiration <= 0 ); } - get shouldUpdateCalendarQuery() { + get shouldUpdateCalendarQuery(): boolean { if (this.props.connection.status !== 'connected' || this.props.frozen) { return false; } const calendarQuery = this.props.currentCalendarQuery(); return !_isEqual(calendarQuery)(this.serverCalendarQuery); } updateCalendarQuery() { const calendarQuery = this.props.currentCalendarQuery(); this.serverCalendarQuery = calendarQuery; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery({ calendarQuery, reduxAlreadyUpdated: true, }), undefined, ({ calendarQuery }: CalendarQueryUpdateStartingPayload), ); } possiblyUpdateCalendarQuery = () => { if (this.shouldUpdateCalendarQuery) { this.updateCalendarQuery(); } }; } const ConnectedCalendarQueryHandler: React.ComponentType = React.memo(function ConnectedCalendarQueryHandler(props) { const connection = useSelector(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const lastUserInteractionCalendar = useSelector( state => state.entryStore.lastUserInteractionCalendar, ); // We include this so that componentDidUpdate will be called on foreground const foreground = useIsAppForegrounded(); const callUpdateCalendarQuery = useUpdateCalendarQuery(); const dispatchActionPromise = useDispatchActionPromise(); const calendarQuery = useSelector(state => state.actualizedCalendarQuery); return ( ); }); export default ConnectedCalendarQueryHandler; diff --git a/lib/socket/inflight-requests.js b/lib/socket/inflight-requests.js index 23c8e81ad..9b6d7979f 100644 --- a/lib/socket/inflight-requests.js +++ b/lib/socket/inflight-requests.js @@ -1,242 +1,240 @@ // @flow import invariant from 'invariant'; import { clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts.js'; import { type ClientServerSocketMessage, type ClientStateSyncServerSocketMessage, type ClientRequestsServerSocketMessage, type ActivityUpdateResponseServerSocketMessage, type PongServerSocketMessage, type APIResponseServerSocketMessage, type ServerSocketMessageType, serverSocketMessageTypes, } from '../types/socket-types.js'; import { ServerError, SocketOffline, SocketTimeout } from '../utils/errors.js'; import sleep from '../utils/sleep.js'; type ValidResponseMessageMap = { a: ClientStateSyncServerSocketMessage, b: ClientRequestsServerSocketMessage, c: ActivityUpdateResponseServerSocketMessage, d: PongServerSocketMessage, e: APIResponseServerSocketMessage, }; type BaseInflightRequest = { expectedResponseType: $PropertyType, resolve: (response: Response) => void, reject: (error: Error) => void, messageID: number, }; type InflightRequestMap = $ObjMap< ValidResponseMessageMap, (T) => BaseInflightRequest<$Exact>, >; type ValidResponseMessage = $Values; type InflightRequest = $Values; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; type Callbacks = { timeout: () => void, setLateResponse: (messageID: number, isLate: boolean) => void, }; class InflightRequests { data: InflightRequest[] = []; timeoutCallback: () => void; setLateResponse: (messageID: number, isLate: boolean) => void; constructor(callbacks: Callbacks) { this.timeoutCallback = callbacks.timeout; this.setLateResponse = callbacks.setLateResponse; } async fetchResponse( messageID: number, expectedType: $PropertyType, ): Promise { let inflightRequest: ?InflightRequest; const responsePromise = new Promise((resolve, reject) => { // Flow makes us do these unnecessary runtime checks... if (expectedType === serverSocketMessageTypes.STATE_SYNC) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.STATE_SYNC, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.REQUESTS) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.REQUESTS, resolve, reject, messageID, }; } else if ( expectedType === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE ) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.PONG) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.PONG, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.API_RESPONSE) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.API_RESPONSE, resolve, reject, messageID, }; } }); - invariant( - inflightRequest, - `${expectedType} is an invalid server response type`, - ); - this.data.push(inflightRequest); + const request = inflightRequest; + invariant(request, `${expectedType} is an invalid server response type`); + this.data.push(request); // We create this object so we can pass it by reference to the timeout // function below. That function will avoid setting this request as late if // the response has already arrived. const requestResult = { concluded: false, lateResponse: false }; try { const response = await Promise.race([ responsePromise, this.timeout(messageID, expectedType, requestResult), ]); requestResult.concluded = true; if (requestResult.lateResponse) { this.setLateResponse(messageID, false); } - this.clearRequest(inflightRequest); + this.clearRequest(request); // Flow is unable to narrow the return type based on the expectedType return (response: any); } catch (e) { requestResult.concluded = true; - this.clearRequest(inflightRequest); + this.clearRequest(request); if (e instanceof SocketTimeout) { this.rejectAll(new Error('socket closed due to timeout')); this.timeoutCallback(); } else if (requestResult.lateResponse) { this.setLateResponse(messageID, false); } throw e; } } async timeout( messageID: number, expectedType: ServerSocketMessageType, requestResult: { concluded: boolean, lateResponse: boolean }, - ) { + ): Promise { await sleep(clientRequestVisualTimeout); if (requestResult.concluded) { // We're just doing this to bail out. If requestResult.concluded we can // conclude that responsePromise already won the race. Returning here // gives Flow errors since Flow is worried response will be undefined. throw new Error(); } requestResult.lateResponse = true; this.setLateResponse(messageID, true); await sleep(remainingTimeAfterVisualTimeout); throw new SocketTimeout(expectedType); } clearRequest(requestToClear: InflightRequest) { this.data = this.data.filter(request => request !== requestToClear); } resolveRequestsForMessage(message: ClientServerSocketMessage) { for (const inflightRequest of this.data) { if ( message.responseTo === null || message.responseTo === undefined || inflightRequest.messageID !== message.responseTo ) { continue; } if (message.type === serverSocketMessageTypes.ERROR) { const error = message.payload ? new ServerError(message.message, message.payload) : new ServerError(message.message); inflightRequest.reject(error); } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { inflightRequest.reject(new SocketOffline('auth_error')); } else if ( message.type === serverSocketMessageTypes.STATE_SYNC && inflightRequest.expectedResponseType === serverSocketMessageTypes.STATE_SYNC ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.REQUESTS && inflightRequest.expectedResponseType === serverSocketMessageTypes.REQUESTS ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE && inflightRequest.expectedResponseType === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.PONG && inflightRequest.expectedResponseType === serverSocketMessageTypes.PONG ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.API_RESPONSE && inflightRequest.expectedResponseType === serverSocketMessageTypes.API_RESPONSE ) { inflightRequest.resolve(message); } } } rejectAll(error: Error) { const { data } = this; // Though the promise rejections below should call clearRequest when they're // caught in fetchResponse, that doesn't happen synchronously. Socket won't // close unless all requests are resolved, so we clear this.data immediately this.data = []; for (const inflightRequest of data) { const { reject } = inflightRequest; reject(error); } } allRequestsResolvedExcept(excludeMessageID: ?number): boolean { for (const inflightRequest of this.data) { const { expectedResponseType } = inflightRequest; if ( expectedResponseType !== serverSocketMessageTypes.PONG && (excludeMessageID === null || excludeMessageID === undefined || excludeMessageID !== inflightRequest.messageID) ) { return false; } } return true; } } export { InflightRequests }; diff --git a/lib/socket/report-handler.react.js b/lib/socket/report-handler.react.js index 31e928ef8..573e5cd37 100644 --- a/lib/socket/report-handler.react.js +++ b/lib/socket/report-handler.react.js @@ -1,89 +1,91 @@ // @flow import * as React from 'react'; import { sendReportsActionTypes } from '../actions/report-actions.js'; import { queuedReports as queuedReportsSelector } from '../selectors/socket-selectors.js'; import { type ClientReportCreationRequest, type ClearDeliveredReportsPayload, } from '../types/report-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { sendReports } from '../utils/reports-service.js'; type BaseProps = { +canSendReports: boolean, }; type Props = { ...BaseProps, +queuedReports: $ReadOnlyArray, +dispatchActionPromise: DispatchActionPromise, }; class ReportHandler extends React.PureComponent { componentDidMount() { if (this.props.canSendReports) { this.dispatchSendReports(this.props.queuedReports); } } componentDidUpdate(prevProps: Props) { if (!this.props.canSendReports) { return; } const couldSend = prevProps.canSendReports; const curReports = this.props.queuedReports; if (!couldSend) { this.dispatchSendReports(curReports); return; } const prevReports = prevProps.queuedReports; if (curReports !== prevReports) { const prevResponses = new Set(prevReports); const newResponses = curReports.filter( response => !prevResponses.has(response), ); this.dispatchSendReports(newResponses); } } - render() { + render(): React.Node { return null; } dispatchSendReports(reports: $ReadOnlyArray) { if (reports.length === 0) { return; } this.props.dispatchActionPromise( sendReportsActionTypes, this.sendReports(reports), ); } - async sendReports(reports: $ReadOnlyArray) { + async sendReports( + reports: $ReadOnlyArray, + ): Promise { await sendReports(reports); - return ({ reports }: ClearDeliveredReportsPayload); + return { reports }; } } const ConnectedReportHandler: React.ComponentType = React.memo(function ConnectedReportHandler(props) { const queuedReports = useSelector(queuedReportsSelector); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }); export default ConnectedReportHandler; diff --git a/lib/socket/request-response-handler.react.js b/lib/socket/request-response-handler.react.js index 063a8aa77..de785d3ba 100644 --- a/lib/socket/request-response-handler.react.js +++ b/lib/socket/request-response-handler.react.js @@ -1,153 +1,153 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { InflightRequests } from './inflight-requests.js'; import { connectionSelector } from '../selectors/keyserver-selectors.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, SocketTimeout } from '../utils/errors.js'; import { useSelector, useDispatch } from '../utils/redux-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; type BaseProps = { +inflightRequests: ?InflightRequests, +sendMessage: (message: ClientSocketMessageWithoutID) => number, +addListener: (listener: SocketListener) => void, +removeListener: (listener: SocketListener) => void, +getClientResponses: ( activeServerRequests: $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() { + render(): React.Node { 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 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, ); } 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(connectionSelector(ashoatKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const dispatch = useDispatch(); return ( ); }); export default ConnectedRequestResponseHandler;