diff --git a/packages/core/src/browser/connection-status-service.spec.ts b/packages/core/src/browser/connection-status-service.spec.ts index bee3657d410db..d7546c8d7466b 100644 --- a/packages/core/src/browser/connection-status-service.spec.ts +++ b/packages/core/src/browser/connection-status-service.spec.ts @@ -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); }); }); diff --git a/packages/core/src/browser/connection-status-service.ts b/packages/core/src/browser/connection-status-service.ts index 6ed44c6363db1..b7396a28d04cf 100644 --- a/packages/core/src/browser/connection-status-service.ts +++ b/packages/core/src/browser/connection-status-service.ts @@ -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. @@ -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; + readonly onStatusChange: Event; } /** - * 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 { @@ -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; } @injectable() -export class FrontendConnectionStatusService implements ConnectionStatusService, FrontendApplicationContribution { +export abstract class AbstractConnectionStatusService implements ConnectionStatusService { - protected readonly statusChangeEmitter: Emitter; - protected readonly aliveUrl: string; + protected readonly statusChangeEmitter = new Emitter(); - 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(); - 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() { @@ -158,54 +96,29 @@ export class FrontendConnectionStatusService implements ConnectionStatusService, return this.connectionState; } - protected schedule(checkAlive: () => Promise) { - 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 { - return new Promise(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); @@ -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 { @@ -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; @@ -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; - } - -} diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 0d8ddfce58036..16199df7aa186 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -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"; @@ -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(); diff --git a/packages/core/src/browser/messaging/ws-connection-provider.ts b/packages/core/src/browser/messaging/ws-connection-provider.ts index 0b8c83e93b1d6..deb63a42d7c4c 100644 --- a/packages/core/src/browser/messaging/ws-connection-provider.ts +++ b/packages/core/src/browser/messaging/ws-connection-provider.ts @@ -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'); @@ -39,6 +39,9 @@ export class WebSocketConnectionProvider { protected readonly socket: WebSocket; protected readonly channels = new Map(); + protected readonly onIncomingMessageActivityEmitter: Emitter = new Emitter(); + public onIncomingMessageActivity: Event = this.onIncomingMessageActivityEmitter.event; + constructor() { const url = this.createWebSocketUrl(WebSocketChannel.wsPath); const socket = this.createWebSocket(url); @@ -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; } diff --git a/packages/core/src/browser/test/mock-connection-status-service.ts b/packages/core/src/browser/test/mock-connection-status-service.ts index f3f0a0b78dac9..9dd3c863a6e94 100644 --- a/packages/core/src/browser/test/mock-connection-status-service.ts +++ b/packages/core/src/browser/test/mock-connection-status-service.ts @@ -15,23 +15,18 @@ ********************************************************************************/ import { MockLogger } from '../../common/test/mock-logger'; -import { FrontendConnectionStatusService } from '../connection-status-service'; +import { AbstractConnectionStatusService } from '../connection-status-service'; -export class MockConnectionStatusService extends FrontendConnectionStatusService { +export class MockConnectionStatusService extends AbstractConnectionStatusService { constructor() { super({ - retry: 3, - maxRetryInterval: 10, - retryInterval: 10, - requestTimeout: 10 + offlineTimeout: 10 }, new MockLogger()); } - public alive: boolean = true; - - protected async checkAlive(): Promise { - return this.alive; + public set alive(alive: boolean) { + this.updateStatus(alive); } } diff --git a/packages/core/src/node/backend-application-module.ts b/packages/core/src/node/backend-application-module.ts index 229eedd487f19..dda4635ab48ed 100644 --- a/packages/core/src/node/backend-application-module.ts +++ b/packages/core/src/node/backend-application-module.ts @@ -20,7 +20,6 @@ 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'; import { ApplicationServerImpl } from "./application-server"; import { ApplicationServer, applicationPath } from "../common/application-protocol"; import { EnvVariablesServer, envVariablesPath } from './../common/env-variables'; @@ -49,9 +48,6 @@ export const backendApplicationModule = new ContainerModule(bind => { bind(IPCConnectionProvider).toSelf().inSingletonScope(); - bind(BackendConnectionStatusEndpoint).toSelf().inSingletonScope(); - bind(BackendApplicationContribution).toDynamicValue(ctx => ctx.container.get(BackendConnectionStatusEndpoint)).inSingletonScope(); - bind(ApplicationServerImpl).toSelf().inSingletonScope(); bind(ApplicationServer).toService(ApplicationServerImpl); bind(ConnectionHandler).toDynamicValue(ctx => diff --git a/packages/core/src/node/backend-connection-status.ts b/packages/core/src/node/backend-connection-status.ts deleted file mode 100644 index bc172515cafa1..0000000000000 --- a/packages/core/src/node/backend-connection-status.ts +++ /dev/null @@ -1,31 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2018 TypeFox and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import * as express from 'express'; -import { injectable } from 'inversify'; -import { BackendApplicationContribution } from './backend-application'; - -@injectable() -export class BackendConnectionStatusEndpoint implements BackendApplicationContribution { - - configure(app: express.Application): void { - app.get('/alive', (request, response) => { - response.contentType('application/json'); - return response.send(); - }); - } - -} diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts index 2df9de019f83e..c84dc7a2a8098 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts @@ -78,7 +78,7 @@ export class HostedPluginInformer implements FrontendApplicationContribution { } private updateStatusBarElement(): void { - if (this.connectionStatusService.currentState.state === ConnectionState.OFFLINE) { + if (this.connectionStatusService.currentState === ConnectionState.OFFLINE) { this.entry.className = HostedPluginInformer.DEVELOPMENT_HOST_OFFLINE; } else { this.entry.className = HostedPluginInformer.DEVELOPMENT_HOST; diff --git a/packages/plugin-ext/src/main/browser/hosted-plugin-controller.ts b/packages/plugin-ext/src/main/browser/hosted-plugin-controller.ts index 750d05f830a4e..733355a0c4a2b 100644 --- a/packages/plugin-ext/src/main/browser/hosted-plugin-controller.ts +++ b/packages/plugin-ext/src/main/browser/hosted-plugin-controller.ts @@ -160,7 +160,7 @@ export class HostedPluginController implements FrontendApplicationContribution { * Updaing status bar element when changing connection status. */ private onConnectionStatusChanged(): void { - if (this.connectionStatusService.currentState.state === ConnectionState.OFFLINE) { + if (this.connectionStatusService.currentState === ConnectionState.OFFLINE) { // Re-set the element only if it's visible on status bar if (this.entry) { const offlineElement = {