Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[connection-status] Use Websockets #2222

Merged
merged 1 commit into from
Jun 28, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions packages/core/src/browser/connection-status-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { enableJSDOM } from '../browser/test/jsdom';
let disableJSDOM = enableJSDOM();

import { expect } from 'chai';
import { ConnectionState } from './connection-status-service';
import { ConnectionStatus } from './connection-status-service';
import { MockConnectionStatusService } from './test/mock-connection-status-service';

disableJSDOM();
Expand All @@ -38,35 +38,35 @@ describe('connection-status', function () {

beforeEach(() => {
connectionStatusService = new MockConnectionStatusService();
connectionStatusService.start();
});

afterEach(() => {
if (connectionStatusService !== undefined) {
connectionStatusService.stop();
connectionStatusService.dispose();
}
});

it('should go from online to offline if the connection is down', async () => {
expect(connectionStatusService.currentState.state).to.be.equal(ConnectionState.INITIAL);
expect(connectionStatusService.currentStatus).to.be.equal(ConnectionStatus.ONLINE);
connectionStatusService.alive = false;
await pause();

expect(connectionStatusService.currentState.state).to.be.equal(ConnectionState.OFFLINE);
expect(connectionStatusService.currentStatus).to.be.equal(ConnectionStatus.OFFLINE);
});

it('should go from offline to online if the connection is re-established', async () => {
expect(connectionStatusService.currentStatus).to.be.equal(ConnectionStatus.ONLINE);
connectionStatusService.alive = false;
await pause();
expect(connectionStatusService.currentState.state).to.be.equal(ConnectionState.OFFLINE);
expect(connectionStatusService.currentStatus).to.be.equal(ConnectionStatus.OFFLINE);

connectionStatusService.alive = true;
await pause();
expect(connectionStatusService.currentState.state).to.be.equal(ConnectionState.ONLINE);
expect(connectionStatusService.currentStatus).to.be.equal(ConnectionStatus.ONLINE);
});

});

function pause(time: number = 100) {
function pause(time: number = 1) {
return new Promise(resolve => setTimeout(resolve, time));
}
238 changes: 84 additions & 154 deletions packages/core/src/browser/connection-status-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@

import { inject, injectable, optional } from 'inversify';
import { ILogger } from '../common/logger';
import { Endpoint } from './endpoint';
import { Event, Emitter } from '../common/event';
import { FrontendApplicationContribution, DefaultFrontendApplicationContribution } from './frontend-application';
import { DefaultFrontendApplicationContribution } from './frontend-application';
import { StatusBar, StatusBarAlignment } from './status-bar/status-bar';
import { WebSocketConnectionProvider } from './messaging/ws-connection-provider';
import { Disposable } from '../common';

/**
* Service for listening on backend connection changes.
Expand All @@ -28,9 +29,9 @@ export const ConnectionStatusService = Symbol('ConnectionStatusService');
export interface ConnectionStatusService {

/**
* The actual connection state.
* The actual connection status.
*/
readonly currentState: ConnectionStatus;
readonly currentStatus: ConnectionStatus;

/**
* Clients can listen on connection status change events.
Expand All @@ -40,181 +41,137 @@ export interface ConnectionStatusService {
}

/**
* Connection status change event.
* The connection status.
*/
export interface ConnectionStatus {

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

}

/**
* The connection-status states.
*/
export enum ConnectionState {
export enum ConnectionStatus {

/**
* Connected to the backend.
*/
ONLINE,

/**
* The connection is lost between the client and the endpoint.
* The connection is lost between frontend and backend.
*/
OFFLINE,

/**
* Initially we don't know whether we are online or offline.
*/
INITIAL
OFFLINE
}

@injectable()
export class ConnectionStatusOptions {

static DEFAULT: ConnectionStatusOptions = {
retry: 5,
retryInterval: 1000,
requestTimeout: 1000,
maxRetryInterval: 10000
offlineTimeout: 5000,
};

/**
* Number of accepted timeouts. Must be a positive integer.
*/
readonly retry: number;

/**
* Retry interval in milliseconds. Must be a positive integer.
*/
readonly retryInterval: number;

/**
* The maximum retry interval in milliseconds. Should be a positive integer.
*
* If the request is timing out because of the slow Internet connection or the server is overloaded, we increase the `retryInterval` until it reaches this `maxRetryInterval`.
* Timeout in milliseconds before the application is considered offline. Must be a positive integer.
*/
readonly maxRetryInterval: number;
readonly offlineTimeout: number;

/**
* Timeout for the HTTP GET request in milliseconds. Must be a positive integer.
*/
readonly requestTimeout: number;
}

export const PingService = Symbol('PingService');
export interface PingService {
ping(): Promise<void>;
}

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

protected readonly statusChangeEmitter: Emitter<ConnectionStatus>;
protected readonly aliveUrl: string;
protected readonly statusChangeEmitter = new Emitter<ConnectionStatus>();

protected connectionState: ConnectionStatusImpl;
protected connectionStatus: ConnectionStatus = ConnectionStatus.ONLINE;
protected timer: number | undefined;
protected retryInterval: number;

constructor(
@inject(ConnectionStatusOptions) @optional() protected readonly options: ConnectionStatusOptions = ConnectionStatusOptions.DEFAULT,
@inject(ILogger) protected readonly logger: ILogger
) {
this.statusChangeEmitter = new Emitter<ConnectionStatus>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please switch to property injection then?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to use it without injection (see mock-connection-service.ts)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, then it makes sense. 👍

this.retryInterval = this.options.retryInterval;
this.connectionState = new ConnectionStatusImpl({ threshold: this.options.retry });
this.aliveUrl = new Endpoint({ path: 'alive' }).getRestUrl().toString();
}

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

onStop() {
this.stop();
get currentStatus() {
return this.connectionStatus;
}

start() {
if (this.timer === undefined) {
this.schedule(this.checkAlive.bind(this));
this.logger.debug('Started checking the backend connection status.');
this.fireStatusChange(this.connectionState);
dispose() {
this.statusChangeEmitter.dispose();
if (this.timer) {
this.clearTimeout(this.timer);
}
}

stop() {
if (this.timer !== undefined) {
protected updateStatus(success: boolean): void {
// clear existing timer
if (this.timer) {
this.clearTimeout(this.timer);
this.timer = undefined;
this.logger.debug('Stopped checking the backend connection status.');
}
this.logger.trace(success ? `Connected to the backend.` : `Cannot reach the backend.`);
const previousStatus = this.connectionStatus;
const newStatus = success ? ConnectionStatus.ONLINE : ConnectionStatus.OFFLINE;
if (previousStatus !== newStatus) {
this.connectionStatus = newStatus;
this.fireStatusChange(newStatus);
}
// schedule offline
this.timer = this.setTimeout(() => {
this.logger.trace(`No activity for ${this.options.offlineTimeout} ms. We are offline.`);
this.updateStatus(false);
}, this.options.offlineTimeout);
}

get onStatusChange() {
return this.statusChangeEmitter.event;
protected fireStatusChange(status: ConnectionStatus) {
this.statusChangeEmitter.fire(status);
}

get currentState() {
return this.connectionState;
// tslint:disable-next-line:no-any
protected setTimeout(handler: (...args: any[]) => void, timeout: number): number {
return window.setTimeout(handler, timeout);
}

protected schedule(checkAlive: () => Promise<boolean>) {
const tick = async () => {
this.logger.trace(`Checking backend connection status. Scheduled an alive request with ${this.retryInterval} ms timeout.`);
const success = await checkAlive();
this.logger.trace(success ? `Connected to the backend.` : `Cannot reach the backend.`);
const previousState = this.connectionState;
const newState = this.updateStatus(success);
if (previousState.state !== newState.state) {
this.fireStatusChange(newState);
}
// Increase the retry interval in a linear scale.
this.retryInterval = success ? this.options.retryInterval : Math.min(this.retryInterval + this.options.retryInterval, this.options.maxRetryInterval);
this.timer = this.setTimeout(tick, this.retryInterval);
};
this.timer = this.setTimeout(tick, this.retryInterval);
protected clearTimeout(handle: number): void {
window.clearTimeout(handle);
}

protected updateStatus(success: boolean): ConnectionStatusImpl {
this.connectionState = this.connectionState.next(success);
return this.connectionState;
}
}

protected fireStatusChange(event: ConnectionStatus) {
this.statusChangeEmitter.fire(event);
}
@injectable()
export class FrontendConnectionStatusService extends AbstractConnectionStatusService {

protected checkAlive(): Promise<boolean> {
return new Promise<boolean>(resolve => {
const handle = (success: boolean) => resolve(success);
const xhr = new XMLHttpRequest();
xhr.timeout = this.options.requestTimeout;
xhr.onreadystatechange = () => {
const { readyState, status } = xhr;
if (readyState === XMLHttpRequest.DONE) {
handle(status === 200);
}
};
xhr.onerror = () => handle(false);
xhr.ontimeout = () => handle(false);
xhr.open('GET', this.aliveUrl);
try {
xhr.send();
} catch {
handle(false);
}
});
}
private scheduledPing: number | undefined;

// tslint:disable-next-line:no-any
protected setTimeout(handler: (...args: any[]) => void, timeout: number): number {
return window.setTimeout(handler, timeout);
constructor(
@inject(WebSocketConnectionProvider) protected readonly wsConnectionProvider: WebSocketConnectionProvider,
@inject(PingService) protected readonly pingService: PingService,
@inject(ConnectionStatusOptions) @optional() protected readonly options: ConnectionStatusOptions = ConnectionStatusOptions.DEFAULT,
@inject(ILogger) protected readonly logger: ILogger
) {
super(options, logger);
this.schedulePing();
this.wsConnectionProvider.onIncomingMessageActivity(() => {
// natural activity
this.updateStatus(true);
this.schedulePing();
});
}

protected clearTimeout(handle: number): void {
window.clearTimeout(handle);
protected schedulePing() {
if (this.scheduledPing) {
this.clearTimeout(this.scheduledPing);
}
this.scheduledPing = this.setTimeout(async () => {
try {
await this.pingService.ping();
this.updateStatus(true);
} catch (e) {
this.logger.trace(e);
}
this.schedulePing();
}, this.options.offlineTimeout * 0.8);
}

}

@injectable()
Expand All @@ -226,16 +183,16 @@ export class ApplicationConnectionStatusContribution extends DefaultFrontendAppl
@inject(ILogger) protected readonly logger: ILogger
) {
super();
this.connectionStatusService.onStatusChange(status => this.onStatusChange(status));
this.connectionStatusService.onStatusChange(state => this.onStateChange(state));
}

protected onStatusChange(status: ConnectionStatus): void {
switch (status.state) {
case ConnectionState.OFFLINE: {
protected onStateChange(state: ConnectionStatus): void {
switch (state) {
case ConnectionStatus.OFFLINE: {
this.handleOffline();
break;
}
case ConnectionState.ONLINE: {
case ConnectionStatus.ONLINE: {
this.handleOnline();
break;
}
Expand All @@ -259,30 +216,3 @@ export class ApplicationConnectionStatusContribution extends DefaultFrontendAppl
});
}
}

export class ConnectionStatusImpl implements ConnectionStatus {

private static readonly MAX_HISTORY = 100;

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

next(success: boolean): ConnectionStatusImpl {
const newHistory = this.updateHistory(success);
const 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 ConnectionStatusImpl(this.props, online ? ConnectionState.ONLINE : ConnectionState.OFFLINE, newHistory);
}

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

}
Loading