Skip to content

Commit

Permalink
GH-1155: Added support to handle the connection issues gracefully.
Browse files Browse the repository at this point in the history
Signed-off-by: Akos Kitta <kittaakos@gmail.com>
  • Loading branch information
kittaakos committed Feb 2, 2018
1 parent ea7fac8 commit 7bfc079
Show file tree
Hide file tree
Showing 5 changed files with 334 additions and 2 deletions.
19 changes: 17 additions & 2 deletions packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
MessageService,
MessageClient
} from "../common";
import { FrontendApplication, FrontendApplicationContribution } from './frontend-application';
import { FrontendApplication, FrontendApplicationContribution, DefaultFrontendApplicationContribution } from './frontend-application';
import { DefaultOpenerService, OpenerService, OpenHandler } from './opener-service';
import { HttpOpenHandler } from './http-open-handler';
import { CommonFrontendContribution } from './common-frontend-contribution';
Expand All @@ -32,14 +32,21 @@ import {
import { StatusBar, StatusBarImpl } from "./status-bar/status-bar";
import { LabelParser } from './label-parser';
import { LabelProvider, LabelProviderContribution, DefaultUriLabelProviderContribution } from "./label-provider";
import { ThemingCommandContribution, ThemeService } from './theming';
import {
FrontendConnectionStatusService,
ApplicationConnectionStatusContribution,
ConnectionStatusStatusBarContribution,
ConnectionStatusService
} from './frontend-connection-status';

import '../../src/browser/style/index.css';
import 'font-awesome/css/font-awesome.min.css';
import "file-icons-js/css/style.css";
import { ThemingCommandContribution, ThemeService } from './theming';

export const frontendApplicationModule = new ContainerModule((bind, unbind, isBound, rebind) => {
bind(FrontendApplication).toSelf().inSingletonScope();
bind(DefaultFrontendApplicationContribution).toSelf();
bindContributionProvider(bind, FrontendApplicationContribution);

bind(ApplicationShellOptions).toConstantValue({});
Expand Down Expand Up @@ -108,6 +115,14 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo
bind(LabelProviderContribution).to(DefaultUriLabelProviderContribution).inSingletonScope();

bind(CommandContribution).to(ThemingCommandContribution).inSingletonScope();

bind(FrontendConnectionStatusService).toSelf().inSingletonScope();
bind(ConnectionStatusService).toDynamicValue(ctx => ctx.container.get(FrontendConnectionStatusService)).inSingletonScope();
bind(FrontendApplicationContribution).toDynamicValue(ctx => ctx.container.get(FrontendConnectionStatusService)).inSingletonScope();
bind(ApplicationConnectionStatusContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toDynamicValue(ctx => ctx.container.get(ApplicationConnectionStatusContribution)).inSingletonScope();
bind(ConnectionStatusStatusBarContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toDynamicValue(ctx => ctx.container.get(ConnectionStatusStatusBarContribution)).inSingletonScope();
});

const theme = ThemeService.get().getCurrentTheme().id;
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/browser/frontend-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ export interface FrontendApplicationContribution {
initializeLayout?(app: FrontendApplication): MaybePromise<void>;
}

/**
* Default frontend contribution that can be extended by clients if they do not want to implement any of the
* methods from the interface but still want to contribute to the frontend application.
*/
@injectable()
export abstract class DefaultFrontendApplicationContribution implements FrontendApplicationContribution {

initialize() {
// NOOP
}

}

@injectable()
export class FrontendApplication {

Expand Down
275 changes: 275 additions & 0 deletions packages/core/src/browser/frontend-connection-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/*
* Copyright (C) 2018 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/

import { inject, injectable, optional } from 'inversify';
import { ILogger } from '../common/logger';
import { Event, Emitter } from '../common/event';
import { AbstractDialog } from './dialogs';
import { MessageService } from '../common/message-service';
import { StatusBar, StatusBarAlignment } from './status-bar/status-bar';
import { FrontendApplicationContribution, DefaultFrontendApplicationContribution } from './frontend-application';

/**
* Service for listening on backend connection changes.
*/
export const ConnectionStatusService = Symbol('ConnectionStatusService');
export interface ConnectionStatusService {
readonly onStatusChange: Event<ConnectionStatusChangeEvent>;
}

/**
* Connection status change event.
*/
export interface ConnectionStatusChangeEvent {

/**
* The current state of the connection.
*/
readonly state: ConnectionState,

/**
* Optional health, percentage number.
*/
readonly health?: number

}

/**
* The connection-status states.
*/
export enum ConnectionState {
ONLINE,
OFFLINE
}

@injectable()
export class ConnectionStatusOptions {

static DEFAULT: ConnectionStatusOptions = {
requestTimeout: 1000,
retry: 5,
retryInterval: 2000,
};

/**
* Timeout for the HTTP GET request in milliseconds.
*/
readonly requestTimeout: number;

/**
* Number of accepted timeouts.
*/
readonly retry: number;

/**
* Retry interval in milliseconds.
*/
readonly retryInterval: number;

}

@injectable()
export class FrontendConnectionStatusService implements ConnectionStatusService, FrontendApplicationContribution {

protected readonly statusChangeEmitter: Emitter<ConnectionStatusChangeEvent>;

private connectionState: ConnectionStateMachine;
private timer: number | undefined;

constructor(
@inject(ConnectionStatusOptions) @optional() protected readonly options: ConnectionStatusOptions = ConnectionStatusOptions.DEFAULT,
) {
this.connectionState = new ConnectionStateMachine({ threshold: this.options.retry });
this.statusChangeEmitter = new Emitter<ConnectionStatusChangeEvent>();
}

onStart() {
this.timer = window.setInterval(() => {
const handle = (success: boolean) => {
this.connectionState = this.connectionState.next(success);
this.statusChangeEmitter.fire(this.connectionState);
};
const xhr = new XMLHttpRequest();
xhr.timeout = this.options.requestTimeout;
xhr.onreadystatechange = event => {
const { readyState, status } = xhr;
if (readyState === XMLHttpRequest.DONE) {
handle(status === 200);
}
};
xhr.onerror = () => handle(false);
xhr.ontimeout = () => handle(false);
xhr.open('GET', `${window.location.href}alive`);
try {
xhr.send();
} catch {
handle(false);
}
}, this.options.retryInterval);
this.statusChangeEmitter.fire(this.connectionState);
}

onStop() {
if (this.timer !== undefined) {
window.clearInterval(this.timer);
this.timer = undefined;
}
}

get onStatusChange() {
return this.statusChangeEmitter.event;
}

}

/**
* Just in case we need to support a bit more sophisticated state transitions than having connection or not.
* For instance, `pending`, `reconnecting`.
*/
class ConnectionStateMachine implements ConnectionStatusChangeEvent {

static readonly MAX_HISTORY = 100;

public readonly health: number;

constructor(
private readonly props: { readonly threshold: number },
public readonly state: ConnectionState = ConnectionState.ONLINE,
private readonly history: boolean[] = []) {

if (this.state === ConnectionState.OFFLINE) {
this.health = 0;
} else {
this.health = this.history.length === 0 ? 100 : Math.round((this.history.filter(success => success).length / this.history.length) * 100);
}
}

next(success: boolean): ConnectionStateMachine {
const newHistory = this.updateHistory(success);
// Initial optimism.
let online = true;
if (newHistory.length > this.props.threshold) {
online = newHistory.slice(-this.props.threshold).some(s => s);
}
// Ideally, we do not switch back to online if we see any `true` items but, let's say, after three consecutive `true`s.
return new ConnectionStateMachine(this.props, online ? ConnectionState.ONLINE : ConnectionState.OFFLINE, newHistory);
}

private updateHistory(success: boolean) {
const updated = [...this.history, success];
if (updated.length > ConnectionStateMachine.MAX_HISTORY) {
updated.shift();
}
return updated;
}

}

@injectable()
export class ConnectionStatusStatusBarContribution extends DefaultFrontendApplicationContribution {

constructor(
@inject(ConnectionStatusService) protected readonly connectionStatusService: ConnectionStatusService,
@inject(StatusBar) protected statusBar: StatusBar
) {
super();
this.connectionStatusService.onStatusChange(event => this.onStatusChange(event));
}

protected onStatusChange(event: ConnectionStatusChangeEvent) {
this.statusBar.removeElement('connection-status');
const text = `$(${this.getStatusIcon(event.health)})`;
const tooltip = event.health ? `Online [Connection health: ${event.health}%]` : 'Offline';
this.statusBar.setElement('connection-status', {
alignment: StatusBarAlignment.RIGHT,
text,
priority: 0,
tooltip
});
}

private getStatusIcon(health: number | undefined) {
if (health === undefined || health === 0) {
return 'exclamation-circle';
}
if (health < 25) {
return 'frown-o';
}
if (health < 50) {
return 'meh-o';
}
return 'smile-o';
}

}

@injectable()
export class ApplicationConnectionStatusContribution extends DefaultFrontendApplicationContribution {

private dialog: ConnectionStatusDialog | undefined;
private state = ConnectionState.ONLINE;

constructor(
@inject(ConnectionStatusService) protected readonly connectionStatusService: ConnectionStatusService,
@inject(MessageService) protected readonly messageService: MessageService,
@inject(ILogger) protected readonly logger: ILogger
) {
super();
this.connectionStatusService.onStatusChange(event => this.onStatusChange(event));
}

protected onStatusChange(event: ConnectionStatusChangeEvent): void {
if (this.state !== event.state) {
this.state = event.state;
switch (event.state) {
case ConnectionState.OFFLINE: {
const message = 'The application connection to the backend is lost. Attempting to reconnect...';
this.logger.error(message);
this.messageService.error(message);
this.getOrCreateDialog().open();
break;
}
case ConnectionState.ONLINE: {
const message = 'Successfully reconnected to the backend.';
this.logger.info(message);
this.messageService.info(message);
if (this.dialog !== undefined) {
this.dialog.dispose();
this.dialog = undefined;
}
break;
}
}
}
}

protected getOrCreateDialog(): ConnectionStatusDialog {
if (this.dialog === undefined) {
this.dialog = new ConnectionStatusDialog();
}
return this.dialog;
}

}

export class ConnectionStatusDialog extends AbstractDialog<void> {

public readonly value: void;

constructor() {
super({ title: 'Not connected' });
const textNode = document.createTextNode('The application connection to the backend is lost. Attempting to reconnect...');
this.closeCrossNode.remove();
this.contentNode.appendChild(textNode);
}

protected onAfterAttach() {
// NOOP.
// We need disable the key listener for escape and return so that the dialog cannot be closed by the user.
}

}
4 changes: 4 additions & 0 deletions packages/core/src/node/backend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { BackendApplication, BackendApplicationContribution, BackendApplicationC
import { CliManager, CliContribution } from './cli';
import { ServerProcess, RemoteMasterProcessFactory, clusterRemoteMasterProcessFactory } from './cluster';
import { IPCConnectionProvider } from "./messaging";
import { BackendConnectionStatusEndpoint } from './backend-connection-status';

export function bindServerProcess(bind: interfaces.Bind, masterFactory: RemoteMasterProcessFactory): void {
bind(RemoteMasterProcessFactory).toConstantValue(masterFactory);
Expand All @@ -34,4 +35,7 @@ export const backendApplicationModule = new ContainerModule(bind => {
bind(MessageService).toSelf().inSingletonScope();

bind(IPCConnectionProvider).toSelf().inSingletonScope();

bind(BackendConnectionStatusEndpoint).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toDynamicValue(ctx => ctx.container.get(BackendConnectionStatusEndpoint)).inSingletonScope();
});
25 changes: 25 additions & 0 deletions packages/core/src/node/backend-connection-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2018 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/

import * as express from 'express';
import { injectable } from 'inversify';
import { BackendApplicationContribution } from './backend-application';

@injectable()
export class BackendConnectionStatusEndpoint implements BackendApplicationContribution {

protected app: express.Application | undefined;

configure(app: express.Application): void {
this.app = app;
}

onStart(): void {
this.app!.get('/alive', (request, response) => response.send());
}

}

0 comments on commit 7bfc079

Please sign in to comment.