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)
  • Loading branch information
svenefftinge committed Jun 28, 2018
1 parent b216840 commit ef3e988
Show file tree
Hide file tree
Showing 9 changed files with 99 additions and 211 deletions.
15 changes: 4 additions & 11 deletions packages/core/src/browser/connection-status-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,31 +38,24 @@ describe('connection-status', function () {

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

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

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

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

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

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

});
Expand Down
224 changes: 73 additions & 151 deletions packages/core/src/browser/connection-status-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@

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';

/**
* Service for listening on backend connection changes.
Expand All @@ -30,29 +30,17 @@ export interface ConnectionStatusService {
/**
* The actual connection state.
*/
readonly currentState: ConnectionStatus;
readonly currentState: ConnectionState;

/**
* Clients can listen on connection status change events.
*/
readonly onStatusChange: Event<ConnectionStatus>;
readonly onStatusChange: Event<ConnectionState>;

}

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

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

}

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

Expand All @@ -62,92 +50,42 @@ export enum ConnectionState {
ONLINE,

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

/**
* Initially we don't know whether we are online or offline.
* The connection is lost between frontend and backend.
*/
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.
* Timeout in milliseconds before the application is considered offline. Must be a positive integer.
*/
readonly retryInterval: number;
readonly offlineTimeout: 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`.
*/
readonly maxRetryInterval: 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 {

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

protected connectionState: ConnectionStatusImpl;
protected connectionState: ConnectionState = ConnectionState.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();
}

onStop() {
this.stop();
}

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

stop() {
if (this.timer !== undefined) {
this.clearTimeout(this.timer);
this.timer = undefined;
this.logger.debug('Stopped checking the backend connection status.');
}
}

get onStatusChange() {
Expand All @@ -158,54 +96,29 @@ export class FrontendConnectionStatusService implements ConnectionStatusService,
return this.connectionState;
}

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 updateStatus(success: boolean): ConnectionStatusImpl {
this.connectionState = this.connectionState.next(success);
return this.connectionState;
protected updateStatus(success: boolean): void {
// clear existing timer
if (this.timer) {
this.clearTimeout(this.timer);
}
this.logger.trace(success ? `Connected to the backend.` : `Cannot reach the backend.`);
const previousState = this.connectionState;
const newState = success ? ConnectionState.ONLINE : ConnectionState.OFFLINE;
if (previousState !== newState) {
this.connectionState = newState;
this.fireStatusChange(newState);
}
// schedule offline
this.timer = this.setTimeout(async () => {
this.logger.trace(`No activity for ${this.options.offlineTimeout} ms. We are offline.`);
this.updateStatus(false);
}, this.options.offlineTimeout);
}

protected fireStatusChange(event: ConnectionStatus) {
protected fireStatusChange(event: ConnectionState) {
this.statusChangeEmitter.fire(event);
}

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);
}
});
}

// tslint:disable-next-line:no-any
protected setTimeout(handler: (...args: any[]) => void, timeout: number): number {
return window.setTimeout(handler, timeout);
Expand All @@ -217,6 +130,42 @@ export class FrontendConnectionStatusService implements ConnectionStatusService,

}

@injectable()
export class FrontendConnectionStatusService extends AbstractConnectionStatusService {

private scheduledPing: number | undefined;

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 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()
export class ApplicationConnectionStatusContribution extends DefaultFrontendApplicationContribution {

Expand All @@ -229,8 +178,8 @@ export class ApplicationConnectionStatusContribution extends DefaultFrontendAppl
this.connectionStatusService.onStatusChange(status => this.onStatusChange(status));
}

protected onStatusChange(status: ConnectionStatus): void {
switch (status.state) {
protected onStatusChange(status: ConnectionState): void {
switch (status) {
case ConnectionState.OFFLINE: {
this.handleOffline();
break;
Expand Down Expand Up @@ -259,30 +208,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;
}

}
11 changes: 10 additions & 1 deletion packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import {
} from './preferences';
import { ContextMenuRenderer } from './context-menu-renderer';
import { ThemingCommandContribution, ThemeService, BuiltinThemeProvider } from './theming';
import { ConnectionStatusService, FrontendConnectionStatusService, ApplicationConnectionStatusContribution } from './connection-status-service';
import { ConnectionStatusService, FrontendConnectionStatusService, ApplicationConnectionStatusContribution, PingService } from './connection-status-service';
import { DiffUriLabelProviderContribution } from './diff-uris';
import { ApplicationServer, applicationPath } from "../common/application-protocol";
import { WebSocketConnectionProvider } from "./messaging";
Expand Down Expand Up @@ -143,6 +143,15 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo
bind(serviceIdentifier).toDynamicValue(ctx => ctx.container.get(PreferenceServiceImpl)).inSingletonScope();
}

bind(PingService).toDynamicValue(ctx => {
// let's reuse a simple and cheap service from this package
const envServer: EnvVariablesServer = ctx.container.get(EnvVariablesServer);
return {
ping() {
return envServer.getValue('does_not_matter');
}
};
});
bind(FrontendConnectionStatusService).toSelf().inSingletonScope();
bind(ConnectionStatusService).toDynamicValue(ctx => ctx.container.get(FrontendConnectionStatusService)).inSingletonScope();
bind(FrontendApplicationContribution).toDynamicValue(ctx => ctx.container.get(FrontendConnectionStatusService)).inSingletonScope();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { injectable, interfaces } from "inversify";
import { createWebSocketConnection, Logger, ConsoleLogger } from "vscode-ws-jsonrpc/lib";
import { ConnectionHandler, JsonRpcProxyFactory, JsonRpcProxy } from "../../common";
import { ConnectionHandler, JsonRpcProxyFactory, JsonRpcProxy, Emitter, Event } from "../../common";
import { WebSocketChannel } from "../../common/messaging/web-socket-channel";
import { Endpoint } from "../endpoint";
const ReconnectingWebSocket = require('reconnecting-websocket');
Expand All @@ -39,6 +39,9 @@ export class WebSocketConnectionProvider {
protected readonly socket: WebSocket;
protected readonly channels = new Map<number, WebSocketChannel>();

protected readonly onIncomingMessageActivityEmitter: Emitter<void> = new Emitter();
public onIncomingMessageActivity: Event<void> = this.onIncomingMessageActivityEmitter.event;

constructor() {
const url = this.createWebSocketUrl(WebSocketChannel.wsPath);
const socket = this.createWebSocket(url);
Expand All @@ -57,6 +60,7 @@ export class WebSocketConnectionProvider {
} else {
console.error('The ws channel does not exist', message.id);
}
this.onIncomingMessageActivityEmitter.fire(undefined);
};
this.socket = socket;
}
Expand Down
Loading

0 comments on commit ef3e988

Please sign in to comment.