Skip to content

Commit

Permalink
If the profile was not found in the profile store (on the server), sh… (
Browse files Browse the repository at this point in the history
firefox-devtools#264)

If the profile was not found in the profile store (on the server), show a message and keep re-requesting it.

Closes firefox-devtools#250
  • Loading branch information
julienw authored May 4, 2017
1 parent e3369ae commit 779b411
Show file tree
Hide file tree
Showing 12 changed files with 328 additions and 53 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ module.exports = {
{ "SwitchCase": 1 }
],
"linebreak-style": [ "error", "unix" ],
"quotes": [ "error", "single" ],
"quotes": [ "error", "single", { "avoidEscape": true } ],
"semi": [ "error", "always" ],
"no-extra-semi": "error",
"comma-dangle": [ "error", "always-multiline" ],
Expand All @@ -51,6 +51,7 @@ module.exports = {
"dot-notation": "error",
"no-alert": "error",
"no-caller": "error",
"no-constant-condition": ["error", { checkLoops: false }],
"no-else-return": "error",
"no-eval": "error",
"no-implied-eval": "error",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"chai": "^3.5.0",
"clamp": "^1.0.1",
"classnames": "^2.2.5",
"common-tags": "^1.4.0",
"copy-to-clipboard": "^3.0.5",
"css-loader": "^0.26.0",
"eslint": "^3.10.2",
Expand Down Expand Up @@ -89,6 +90,7 @@
"reselect": "^2.5.1",
"rimraf": "^2.5.4",
"shallowequal": "^0.2.2",
"sinon": "^2.1.0",
"style-loader": "^0.13.1",
"text-encoding": "^0.6.4",
"url": "^0.11.0",
Expand Down
92 changes: 82 additions & 10 deletions src/content/actions/receive-profile.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// @flow
import { oneLine } from 'common-tags';
import { getProfile } from '../reducers/profile-view';
import { processProfile, unserializeProfileOfArbitraryFormat } from '../process-profile';
import { SymbolStore } from '../symbol-store';
import { symbolicateProfile } from '../symbolication';
import { decompress } from '../gz';
import { getTimeRangeIncludingAllThreads } from '../profile-data';
import { TemporaryError } from '../errors';

import type {
Action,
Expand Down Expand Up @@ -232,24 +234,95 @@ export function receiveProfileFromWeb(profile: Profile): ThunkAction {
};
}

export function errorReceivingProfileFromWeb(error: any): Action {
export function temporaryErrorReceivingProfileFromWeb(error: TemporaryError): Action {
return {
type: 'ERROR_RECEIVING_PROFILE_FROM_WEB',
type: 'TEMPORARY_ERROR_RECEIVING_PROFILE_FROM_WEB',
error,
};
}

export function fatalErrorReceivingProfileFromWeb(error: Error): Action {
return {
type: 'FATAL_ERROR_RECEIVING_PROFILE_FROM_WEB',
error,
};
}

function _wait(delayMs) {
return new Promise(resolve => setTimeout(resolve, delayMs));
}

type FetchProfileArgs = {
url: string,
onTemporaryError: TemporaryError => void,
};

/**
* Tries to fetch a profile on `url`. If the profile is not found,
* `onTemporaryError` is called with an appropriate error, we wait 1 second, and
* then tries again. If we still can't find the profile after 11 tries, the
* returned promise is rejected with a fatal error.
* If we can retrieve the profile properly, the returned promise is resolved
* with the JSON.parsed profile.
*/
async function _fetchProfile({ url, onTemporaryError }: FetchProfileArgs) {
const MAX_WAIT_SECONDS = 10;
let i = 0;

while (true) {
const response = await fetch(url);
// Case 1: successful answer.
if (response.ok) {
const json = await response.json();
return json;
}

// case 2: unrecoverable error.
if (response.status !== 404) {
throw new Error(oneLine`
Could not fetch the profile on remote server.
Response was: ${response.status} ${response.statusText}.
`);
}

// case 3: 404 errors can be transient while a profile is uploaded.

if (i++ === MAX_WAIT_SECONDS) {
// In the last iteration we don't send a temporary error because we'll
// throw an error right after the while loop.
break;
}

onTemporaryError(new TemporaryError(
'Profile not found on remote server.',
{ count: i, total: MAX_WAIT_SECONDS + 1 } // 11 tries during 10 seconds
));

await _wait(1000);
}

throw new Error(oneLine`
Could not fetch the profile on remote server:
still not found after ${MAX_WAIT_SECONDS} seconds.
`);
}

export function retrieveProfileFromWeb(hash: string): ThunkAction {
return dispatch => {
return async function (dispatch) {
dispatch(waitingForProfileFromWeb());

fetch(`https://profile-store.commondatastorage.googleapis.com/${hash}`).then(response => response.text()).then(text => {
const profile = unserializeProfileOfArbitraryFormat(text);
try {
const serializedProfile = await _fetchProfile({
url: `https://profile-store.commondatastorage.googleapis.com/${hash}`,
onTemporaryError: e => dispatch(temporaryErrorReceivingProfileFromWeb(e)),
});

const profile = unserializeProfileOfArbitraryFormat(serializedProfile);
if (profile === undefined) {
throw new Error('Unable to parse the profile.');
}

if (window.legacyRangeFilters) {
if (typeof window !== 'undefined' && window.legacyRangeFilters) {
const zeroAt = getTimeRangeIncludingAllThreads(profile).start;
window.legacyRangeFilters.forEach(
({ start, end }) => dispatch({
Expand All @@ -261,10 +334,9 @@ export function retrieveProfileFromWeb(hash: string): ThunkAction {
}

dispatch(receiveProfileFromWeb(profile));

}).catch(error => {
dispatch(errorReceivingProfileFromWeb(error));
});
} catch (error) {
dispatch(fatalErrorReceivingProfileFromWeb(error));
}
};
}

Expand Down
6 changes: 4 additions & 2 deletions src/content/actions/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Profile, Thread, ThreadIndex, IndexIntoMarkersTable, IndexIntoFunc
import type { State } from '../reducers/types';
import type { GetLabel } from '../labeling-strategies';
import type { GetCategory } from '../color-categories';
import type { TemporaryError } from '../errors';

export type ExpandedSet = Set<ThreadIndex>;
export type PrefixCallTreeFilter = {
Expand Down Expand Up @@ -43,7 +44,7 @@ type ProfileSummaryAction =
{ type: "PROFILE_SUMMARY_COLLAPSE", threadIndex: ThreadIndex };

type ProfileAction =
{ type: "FILE_NOT_FOUND", url: string } |
{ type: "ROUTE_NOT_FOUND", url: string } |
{ type: 'CHANGE_THREAD_ORDER', threadOrder: ThreadIndex[] } |
{ type: 'HIDE_THREAD', threadIndex: ThreadIndex } |
{ type: 'SHOW_THREAD', threads: Thread[], threadIndex: ThreadIndex } |
Expand All @@ -61,7 +62,8 @@ type ReceiveProfileAction =
} |
{ type: 'DONE_SYMBOLICATING' } |
{ type: 'ERROR_RECEIVING_PROFILE_FROM_FILE', error: any } |
{ type: 'ERROR_RECEIVING_PROFILE_FROM_WEB', error: any } |
{ type: 'TEMPORARY_ERROR_RECEIVING_PROFILE_FROM_WEB', error: TemporaryError } |
{ type: 'FATAL_ERROR_RECEIVING_PROFILE_FROM_WEB', error: Error } |
{ type: 'PROFILE_PROCESSED', profile: Profile, toWorker: true } |
{ type: "RECEIVE_PROFILE_FROM_ADDON", profile: Profile } |
{ type: "RECEIVE_PROFILE_FROM_FILE", profile: Profile } |
Expand Down
75 changes: 59 additions & 16 deletions src/content/containers/Root.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,36 @@ import { getView } from '../reducers/app';
import { getDataSource, getHash } from '../reducers/url-state';
import URLManager from './URLManager';

import type { State, AppViewState } from '../reducers/types';
import type { Action } from '../actions/types';

const LOADING_MESSAGES = Object.freeze({
'from-addon': 'Retrieving profile from the gecko profiler addon...',
'from-file': 'Reading the file and parsing the profile in it...',
'local': 'Not implemented yet.',
'public': 'Retrieving profile from the public profile store...',
});

// TODO Switch to a proper i18n library
function fewTimes(count: number) {
switch (count) {
case 1: return 'once';
case 2: return 'twice';
default: return `${count} times`;
}
}

type ProfileViewProps = {
view: AppViewState,
dataSource: string,
hash: string,
retrieveProfileFromAddon: void => void,
retrieveProfileFromWeb: string => void,
};

class ProfileViewWhenReadyImpl extends Component {
props: ProfileViewProps;

componentDidMount() {
const { dataSource, hash, retrieveProfileFromAddon, retrieveProfileFromWeb } = this.props;
switch (dataSource) {
Expand All @@ -28,26 +57,31 @@ class ProfileViewWhenReadyImpl extends Component {

render() {
const { view, dataSource } = this.props;
switch (view) {
switch (view.phase) {
case 'INITIALIZING': {
switch (dataSource) {
case 'none':
return <Home />;
case 'from-addon':
return <div>Retrieving profile from the gecko profiler addon...</div>;
case 'from-file':
return <div>Reading the file and parsing the profile in it...</div>;
case 'local':
return <div>Not implemented yet.</div>;
case 'public':
return <div>Retrieving profile from the public profile store...</div>;
default:
return <div>View not found.</div>;
if (dataSource === 'none') {
return <Home />;
}

const message = LOADING_MESSAGES[dataSource] || 'View not found';
let additionalMessage = null;
if (view.additionalData && view.additionalData.attempt) {
const attempt = view.additionalData.attempt;
additionalMessage = `Tried ${fewTimes(attempt.count)} out of ${attempt.total}.`;
}

return (
<div>
<div>{ message }</div>
{ additionalMessage && <div>{ additionalMessage }</div>}
</div>
);
}
case 'FATAL_ERROR':
return <div>{"Couldn't load the profile from the store."}</div>;
case 'PROFILE':
return <ProfileViewer/>;
case 'FILE_NOT_FOUND':
case 'ROUTE_NOT_FOUND':
return <div>There is no route handler for the URL {window.location.pathname + window.location.search}</div>;
default:
return <div>View not found.</div>;
Expand All @@ -56,7 +90,10 @@ class ProfileViewWhenReadyImpl extends Component {
}

ProfileViewWhenReadyImpl.propTypes = {
view: PropTypes.string.isRequired,
view: PropTypes.shape({
phase: PropTypes.string.isRequired,
additionalMessage: PropTypes.object,
}).isRequired,
dataSource: PropTypes.string.isRequired,
hash: PropTypes.string,
retrieveProfileFromAddon: PropTypes.func.isRequired,
Expand All @@ -69,7 +106,13 @@ const ProfileViewWhenReady = connect(state => ({
hash: getHash(state),
}), actions)(ProfileViewWhenReadyImpl);

type RootProps = {
store: Store<State, Action>,
};

export default class Root extends Component {
props: RootProps;

render() {
const { store } = this.props;
return (
Expand Down
2 changes: 1 addition & 1 deletion src/content/containers/URLManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,5 @@ export default connect(state => ({
}), (dispatch: Dispatch) => ({
updateURLState: urlState => dispatch({ type: '@@urlenhancer/updateURLState', urlState }),
urlSetupDone: () => dispatch({ type: '@@urlenhancer/urlSetupDone' }),
show404: url => dispatch({ type: 'FILE_NOT_FOUND', url }),
show404: url => dispatch({ type: 'ROUTE_NOT_FOUND', url }),
}))(URLManager);
16 changes: 16 additions & 0 deletions src/content/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @flow

type Attempt = {
count: number,
total: number,
};

export class TemporaryError extends Error {
attempt: Attempt;

constructor(message: string, attempt: Attempt) {
super(message);
this.name = 'TemporaryError';
this.attempt = attempt;
}
}
19 changes: 13 additions & 6 deletions src/content/reducers/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@
import { combineReducers } from 'redux';

import type { Action } from '../actions/types';
import type { State, AppState, Reducer } from './types';
import type { State, AppState, AppViewState, Reducer } from './types';

function view(state: string = 'INITIALIZING', action: Action) {
function view(state: AppViewState = { phase: 'INITIALIZING' }, action: Action): AppViewState {
switch (action.type) {
case 'FILE_NOT_FOUND':
return 'FILE_NOT_FOUND';
case 'TEMPORARY_ERROR_RECEIVING_PROFILE_FROM_WEB':
return {
phase: 'INITIALIZING',
additionalData: { attempt: action.error.attempt },
};
case 'FATAL_ERROR_RECEIVING_PROFILE_FROM_WEB':
return { phase: 'FATAL_ERROR' };
case 'ROUTE_NOT_FOUND':
return { phase: 'ROUTE_NOT_FOUND' };
case 'RECEIVE_PROFILE_FROM_ADDON':
case 'RECEIVE_PROFILE_FROM_WEB':
case 'RECEIVE_PROFILE_FROM_FILE':
return 'PROFILE';
return { phase: 'PROFILE' };
default:
return state;
}
Expand All @@ -29,5 +36,5 @@ const appStateReducer: Reducer<AppState> = combineReducers({ view, isURLSetupDon
export default appStateReducer;

export const getApp = (state: State): AppState => state.app;
export const getView = (state: State): string => getApp(state).view;
export const getView = (state: State): AppViewState => getApp(state).view;
export const getIsURLSetupDone = (state: State): boolean => getApp(state).isURLSetupDone;
7 changes: 6 additions & 1 deletion src/content/reducers/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ export type ProfileViewState = {
profile: Profile,
};

export type AppViewState = {
phase: string,
additionalData?: Object,
};

export type AppState = {
view: string,
view: AppViewState,
isURLSetupDone: boolean,
};

Expand Down
Loading

0 comments on commit 779b411

Please sign in to comment.