diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Connection.spec.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Connection.spec.ts index a379dd2810..2d7f54fe79 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Connection.spec.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Connection.spec.ts @@ -320,7 +320,7 @@ describe("Connection", () => { // mode: TransferMode : TransferMode.Text connect(url: string, requestedTransferMode: TransferMode): Promise { return Promise.resolve(transportTransferMode); }, send(data: any): Promise { return Promise.resolve(); }, - stop(): void {}, + stop(): void { }, onreceive: null, onclose: null, mode: transportTransferMode diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HubConnection.spec.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HubConnection.spec.ts index a35d124345..a34e080af4 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HubConnection.spec.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HubConnection.spec.ts @@ -10,7 +10,8 @@ import { TextMessageFormat } from "../Microsoft.AspNetCore.SignalR.Client.TS/For import { ILogger, LogLevel } from "../Microsoft.AspNetCore.SignalR.Client.TS/ILogger" import { MessageType } from "../Microsoft.AspNetCore.SignalR.Client.TS/IHubProtocol" -import { asyncit as it, captureException } from './JasmineUtils'; +import { asyncit as it, captureException, delay, PromiseSource } from './Utils'; +import { IHubConnectionOptions } from "../Microsoft.AspNetCore.SignalR.Client.TS/IHubConnectionOptions"; describe("HubConnection", () => { @@ -437,7 +438,46 @@ describe("HubConnection", () => { connection.receive({ type: MessageType.Completion, invocationId: connection.lastInvocationId, result: "foo" }); expect(await invokePromise).toBe("foo"); - }) + }); + + it("does not terminate if messages are received", async () => { + let connection = new TestConnection(); + let hubConnection = new HubConnection(connection, { serverTimeoutInMilliseconds: 100 }); + + let p = new PromiseSource(); + hubConnection.onclose(error => p.resolve(error)); + + await hubConnection.start(); + + await connection.receive({ type: MessageType.Ping }); + await delay(50); + await connection.receive({ type: MessageType.Ping }); + await delay(50); + await connection.receive({ type: MessageType.Ping }); + await delay(50); + await connection.receive({ type: MessageType.Ping }); + await delay(50); + + connection.stop(); + + let error = await p.promise; + + expect(error).toBeUndefined(); + }); + + it("terminates if no messages received within timeout interval", async () => { + let connection = new TestConnection(); + let hubConnection = new HubConnection(connection, { serverTimeoutInMilliseconds: 100 }); + + let p = new PromiseSource(); + hubConnection.onclose(error => p.resolve(error)); + + await hubConnection.start(); + + let error = await p.promise; + + expect(error).toEqual(new Error("Server timeout elapsed without receiving a message from the server.")); + }); }) }); @@ -463,9 +503,9 @@ class TestConnection implements IConnection { return Promise.resolve(); }; - stop(): void { + stop(error?: Error): void { if (this.onclose) { - this.onclose(); + this.onclose(error); } }; @@ -505,26 +545,4 @@ class TestObserver implements Observer complete() { this.itemsSource.resolve(this.itemsReceived); } -}; - -class PromiseSource { - public promise: Promise - - private resolver: (value?: T | PromiseLike) => void; - private rejecter: (reason?: any) => void; - - constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolver = resolve; - this.rejecter = reject; - }); - } - - resolve(value?: T | PromiseLike) { - this.resolver(value); - } - - reject(reason?: any) { - this.rejecter(reason); - } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/JasmineUtils.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Utils.ts similarity index 51% rename from client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/JasmineUtils.ts rename to client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Utils.ts index c2d3368556..48f62664aa 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/JasmineUtils.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Utils.ts @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +import { clearTimeout, setTimeout } from "timers"; + export function asyncit(expectation: string, assertion?: () => Promise, timeout?: number): void { let testFunction: (done: DoneFn) => void; if (assertion) { @@ -24,4 +26,32 @@ export async function captureException(fn: () => Promise): Promise { } catch (e) { return e; } +} + +export function delay(durationInMilliseconds: number): Promise { + let source = new PromiseSource(); + setTimeout(() => source.resolve(), durationInMilliseconds); + return source.promise; +} + +export class PromiseSource { + public promise: Promise + + private resolver: (value?: T | PromiseLike) => void; + private rejecter: (reason?: any) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolver = resolve; + this.rejecter = reject; + }); + } + + resolve(value?: T | PromiseLike) { + this.resolver(value); + } + + reject(reason?: any) { + this.rejecter(reason); + } } \ No newline at end of file diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts index 9ff2ae2173..f67f9049ec 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts @@ -89,7 +89,7 @@ export class HttpConnection implements IConnection { ? TransferMode.Binary : TransferMode.Text; - this.features.transferMode = await this.transport.connect(this.url, requestedTransferMode); + this.features.transferMode = await this.transport.connect(this.url, requestedTransferMode, this); // only change the state if we were connecting to not overwrite // the state if the connection is already marked as Disconnected @@ -144,7 +144,7 @@ export class HttpConnection implements IConnection { return this.transport.send(data); } - async stop(): Promise { + async stop(error? : Error): Promise { let previousState = this.connectionState; this.connectionState = ConnectionState.Disconnected; @@ -154,10 +154,10 @@ export class HttpConnection implements IConnection { catch (e) { // this exception is returned to the user as a rejected Promise from the start method } - this.stopConnection(/*raiseClosed*/ previousState == ConnectionState.Connected); + this.stopConnection(/*raiseClosed*/ previousState == ConnectionState.Connected, error); } - private stopConnection(raiseClosed: Boolean, error?: any) { + private stopConnection(raiseClosed: Boolean, error?: Error) { if (this.transport) { this.transport.stop(); this.transport = null; @@ -209,4 +209,4 @@ export class HttpConnection implements IConnection { onreceive: DataReceived; onclose: ConnectionClosed; -} \ No newline at end of file +} diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HubConnection.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HubConnection.ts index 00cc491640..716cef0abb 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HubConnection.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HubConnection.ts @@ -13,6 +13,7 @@ import { Base64EncodedHubProtocol } from "./Base64EncodedHubProtocol" import { ILogger, LogLevel } from "./ILogger" import { ConsoleLogger, NullLogger, LoggerFactory } from "./Loggers" import { IHubConnectionOptions } from "./IHubConnectionOptions" +import { setTimeout, clearTimeout } from "timers"; export { TransportType } from "./Transports" export { HttpConnection } from "./HttpConnection" @@ -20,6 +21,8 @@ export { JsonHubProtocol } from "./JsonHubProtocol" export { LogLevel, ILogger } from "./ILogger" export { ConsoleLogger, NullLogger } from "./Loggers" +const DEFAULT_SERVER_TIMEOUT_IN_MS: number = 30 * 1000; + export class HubConnection { private readonly connection: IConnection; private readonly logger: ILogger; @@ -28,9 +31,14 @@ export class HubConnection { private methods: Map void)[]>; private id: number; private closedCallbacks: ConnectionClosed[]; + private timeoutHandle: NodeJS.Timer; + private serverTimeoutInMilliseconds: number; constructor(urlOrConnection: string | IConnection, options: IHubConnectionOptions = {}) { options = options || {}; + + this.serverTimeoutInMilliseconds = options.serverTimeoutInMilliseconds || DEFAULT_SERVER_TIMEOUT_IN_MS; + if (typeof urlOrConnection === "string") { this.connection = new HttpConnection(urlOrConnection, options); } @@ -51,6 +59,10 @@ export class HubConnection { } private processIncomingData(data: any) { + if (this.timeoutHandle !== undefined) { + clearTimeout(this.timeoutHandle); + } + // Parse the messages let messages = this.protocol.parseMessages(data); @@ -79,6 +91,21 @@ export class HubConnection { break; } } + + this.configureTimeout(); + } + + private configureTimeout() { + if (!this.connection.features || !this.connection.features.inherentKeepAlive) { + // Set the timeout timer + this.timeoutHandle = setTimeout(() => this.serverTimeout(), this.serverTimeoutInMilliseconds); + } + } + + private serverTimeout() { + // The server hasn't talked to us in a while. It doesn't like us anymore ... :( + // Terminate the connection + this.connection.stop(new Error("Server timeout elapsed without receiving a message from the server.")); } private invokeClientMethod(invocationMessage: InvocationMessage) { @@ -122,6 +149,8 @@ export class HubConnection { if (requestedTransferMode === TransferMode.Binary && actualTransferMode === TransferMode.Text) { this.protocol = new Base64EncodedHubProtocol(this.protocol); } + + this.configureTimeout(); } stop(): void { diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IConnection.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IConnection.ts index 5eed29c079..eb0a961074 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IConnection.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IConnection.ts @@ -9,8 +9,8 @@ export interface IConnection { start(): Promise; send(data: any): Promise; - stop(): void; + stop(error?: Error): void; onreceive: DataReceived; onclose: ConnectionClosed; -} \ No newline at end of file +} diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHubConnectionOptions.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHubConnectionOptions.ts index 9aa80a1cfe..c45b7accad 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHubConnectionOptions.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHubConnectionOptions.ts @@ -7,4 +7,5 @@ import { ILogger, LogLevel } from "./ILogger" export interface IHubConnectionOptions extends IHttpConnectionOptions { protocol?: IHubProtocol; + serverTimeoutInMilliseconds?: number; } diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts index a6179a05ec..d9f590cead 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts @@ -5,6 +5,7 @@ import { DataReceived, TransportClosed } from "./Common" import { IHttpClient } from "./HttpClient" import { HttpError } from "./HttpError" import { ILogger, LogLevel } from "./ILogger" +import { IConnection } from "./IConnection" export enum TransportType { WebSockets, @@ -18,7 +19,7 @@ export const enum TransferMode { } export interface ITransport { - connect(url: string, requestedTransferMode: TransferMode): Promise; + connect(url: string, requestedTransferMode: TransferMode, connection: IConnection): Promise; send(data: any): Promise; stop(): void; onreceive: DataReceived; @@ -35,7 +36,7 @@ export class WebSocketTransport implements ITransport { this.jwtBearer = jwtBearer; } - connect(url: string, requestedTransferMode: TransferMode): Promise { + connect(url: string, requestedTransferMode: TransferMode, connection: IConnection): Promise { return new Promise((resolve, reject) => { url = url.replace(/^http/, "ws"); @@ -113,7 +114,7 @@ export class ServerSentEventsTransport implements ITransport { this.logger = logger; } - connect(url: string, requestedTransferMode: TransferMode): Promise { + connect(url: string, requestedTransferMode: TransferMode, connection: IConnection): Promise { if (typeof (EventSource) === "undefined") { Promise.reject("EventSource not supported by the browser."); } @@ -194,10 +195,13 @@ export class LongPollingTransport implements ITransport { this.logger = logger; } - connect(url: string, requestedTransferMode: TransferMode): Promise { + connect(url: string, requestedTransferMode: TransferMode, connection: IConnection): Promise { this.url = url; this.shouldPoll = true; + // Set a flag indicating we have inherent keep-alive in this transport. + connection.features.inherentKeepAlive = true; + if (requestedTransferMode === TransferMode.Binary && (typeof new XMLHttpRequest().responseType !== "string")) { // This will work if we fix: https://github.com/aspnet/SignalR/issues/742 throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported."); @@ -300,4 +304,4 @@ async function send(httpClient: IHttpClient, url: string, jwtBearer: () => strin } await httpClient.post(url, data, headers); -} \ No newline at end of file +} diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Test.Server/wwwroot/js/hubConnectionTests.js b/client-ts/Microsoft.AspNetCore.SignalR.Test.Server/wwwroot/js/hubConnectionTests.js index de124e7d8c..a6d4338395 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Test.Server/wwwroot/js/hubConnectionTests.js +++ b/client-ts/Microsoft.AspNetCore.SignalR.Test.Server/wwwroot/js/hubConnectionTests.js @@ -56,7 +56,7 @@ describe('hubConnection', function () { }); hubConnection.start().then(function () { - hubConnection.send('SendCustomObject', { Name: 'test', Value: 42}); + hubConnection.send('SendCustomObject', { Name: 'test', Value: 42 }); }).catch(function (e) { fail(e); done(); @@ -258,7 +258,7 @@ describe('hubConnection', function () { hubConnection.start().then(function () { return hubConnection.invoke('InvokeWithString', message); }) - .then(function() { + .then(function () { return hubConnection.stop(); }) .catch(function (e) { @@ -367,18 +367,42 @@ describe('hubConnection', function () { }); return hubConnection.start(); }) - .then(function() { + .then(function () { return hubConnection.invoke('Echo', message); }) - .then(function(response) { + .then(function (response) { expect(response).toEqual(message); return hubConnection.stop(); }) - .catch(function(e) { + .catch(function (e) { fail(e); done(); }); }); + + if (transportType != signalR.TransportType.LongPolling) { + it("terminates if no messages received within timeout interval", function (done) { + var options = { + transport: transportType, + logging: signalR.LogLevel.Trace, + serverTimeoutInMilliseconds: 100 + }; + + var hubConnection = new signalR.HubConnection(TESTHUBENDPOINT_URL, options); + + var timeout = setTimeout(200, function () { + fail("Server timeout did not fire within expected interval"); + }); + + hubConnection.start().then(function () { + hubConnection.onclose(function (error) { + clearTimeout(timeout); + expect(error).toEqual(new Error("Server timeout elapsed without receiving a message from the server.")); + done(); + }); + }); + }); + } }); });