Skip to content

Commit

Permalink
[connection-status] Use Websockets
Browse files Browse the repository at this point in the history
Changed the connection status service to use websockets
and natural activity. (fixes #1490)

Signed-off-by: svenefftinge <sven.efftinge@typefox.io>
  • Loading branch information
svenefftinge committed Jun 28, 2018
1 parent 1683302 commit 3fbd5e5
Show file tree
Hide file tree
Showing 9 changed files with 116 additions and 213 deletions.
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>();
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

0 comments on commit 3fbd5e5

Please sign in to comment.