Skip to content

Implement auto reconnect on SignalR TypeScript client #8566

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

Merged
merged 14 commits into from
Apr 8, 2019
Merged
31 changes: 31 additions & 0 deletions src/SignalR/clients/ts/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Node - Attach by Process ID",
"processId": "${command:PickProcess}"
},
{
"type": "node",
"request": "launch",
"name": "Jest - All",
"program": "${workspaceFolder}/common/node_modules/jest/bin/jest",
"cwd": "${workspaceFolder}",
"args": ["--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Jest - Current File",
"program": "${workspaceFolder}/common/node_modules/jest/bin/jest",
"cwd": "${workspaceFolder}",
"args": ["${fileBasename}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@

<Target Name="RunBrowserTests">
<Message Text="Running JavaScript client Browser tests" Importance="high" />
<Yarn Command="run test:inner -- --no-color --configuration $(Configuration)" WorkingDirectory="$(RepositoryRoot)src/SignalR/clients/ts/FunctionalTests" />
<Message Text="Running JavaScript tests" Importance="high" />

<!-- Skip the "inner" test run when we're running DailyTests -->
<Yarn Command="run test:inner -- --no-color --configuration $(Configuration)"
Condition="'$(DailyTests)' != 'true'"
Expand Down
14 changes: 14 additions & 0 deletions src/SignalR/clients/ts/FunctionalTests/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.IO;
using System.Reflection;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
Expand All @@ -29,6 +30,8 @@ public class Startup
private readonly SymmetricSecurityKey SecurityKey = new SymmetricSecurityKey(Guid.NewGuid().ToByteArray());
private readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler();

private int _numRedirects;

public void ConfigureServices(IServiceCollection services)
{
services.AddConnections();
Expand Down Expand Up @@ -126,6 +129,17 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

app.UseRouting();

app.Use((context, next) =>
{
if (context.Request.Path.StartsWithSegments("/redirect"))
{
var newUrl = context.Request.Query["baseUrl"] + "/testHub?numRedirects=" + Interlocked.Increment(ref _numRedirects);
return context.Response.WriteAsync($"{{ \"url\": \"{newUrl}\" }}");
}

return next();
});

app.Use(async (context, next) =>
{
if (context.Request.Path.Value.Contains("/negotiate"))
Expand Down
5 changes: 5 additions & 0 deletions src/SignalR/clients/ts/FunctionalTests/TestHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public string Echo(string message)
return message;
}

public int GetNumRedirects()
{
return int.Parse(Context.GetHttpContext().Request.Query["numRedirects"]);
}

public void ThrowException(string message)
{
throw new InvalidOperationException(message);
Expand Down
145 changes: 142 additions & 3 deletions src/SignalR/clients/ts/FunctionalTests/ts/HubConnectionTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import { eachTransport, eachTransportAndProtocol, ENDPOINT_BASE_HTTPS_URL, ENDPO
import "./LogBannerReporter";
import { TestLogger } from "./TestLogger";

import { PromiseSource } from "../../signalr/tests/Utils";

import * as RX from "rxjs";

const TESTHUBENDPOINT_URL = ENDPOINT_BASE_URL + "/testhub";
const TESTHUBENDPOINT_HTTPS_URL = ENDPOINT_BASE_HTTPS_URL ? (ENDPOINT_BASE_HTTPS_URL + "/testhub") : undefined;

const TESTHUB_NOWEBSOCKETS_ENDPOINT_URL = ENDPOINT_BASE_URL + "/testhub-nowebsockets";
const TESTHUB_REDIRECT_ENDPOINT_URL = ENDPOINT_BASE_URL + "/redirect?numRedirects=0&baseUrl=" + ENDPOINT_BASE_URL;

const commonOptions: IHttpConnectionOptions = {
logMessageContent: true,
Expand Down Expand Up @@ -421,16 +424,25 @@ describe("hubConnection", () => {
});
});

it("closed with error if hub cannot be created", (done) => {
it("closed with error or start fails if hub cannot be created", async (done) => {
const hubConnection = getConnectionBuilder(transportType, ENDPOINT_BASE_URL + "/uncreatable")
.withHubProtocol(protocol)
.build();

const expectedErrorMessage = "Server returned an error on close: Connection closed with an error. InvalidOperationException: Unable to resolve service for type 'System.Object' while attempting to activate 'FunctionalTests.UncreatableHub'.";

// Either start will fail or onclose will be called. Never both.
hubConnection.onclose((error) => {
expect(error!.message).toEqual("Server returned an error on close: Connection closed with an error. InvalidOperationException: Unable to resolve service for type 'System.Object' while attempting to activate 'FunctionalTests.UncreatableHub'.");
expect(error!.message).toEqual(expectedErrorMessage);
done();
});
hubConnection.start();

try {
await hubConnection.start();
} catch (error) {
expect(error!.message).toEqual(expectedErrorMessage);
done();
}
});

it("can handle different types", (done) => {
Expand Down Expand Up @@ -696,9 +708,136 @@ describe("hubConnection", () => {
await hubConnection.stop();
done();
});

it("can reconnect", async (done) => {
try {
const reconnectingPromise = new PromiseSource();
const reconnectedPromise = new PromiseSource<string | undefined>();
const hubConnection = getConnectionBuilder(transportType)
.withAutomaticReconnect()
.build();

hubConnection.onreconnecting(() => {
reconnectingPromise.resolve();
});

hubConnection.onreconnected((connectionId?) => {
reconnectedPromise.resolve(connectionId);
});

await hubConnection.start();

const initialConnectionId = (hubConnection as any).connection.connectionId as string;

// Induce reconnect
(hubConnection as any).serverTimeout();

await reconnectingPromise;
const newConnectionId = await reconnectedPromise;

expect(newConnectionId).not.toBe(initialConnectionId);

const response = await hubConnection.invoke("Echo", "test");

expect(response).toEqual("test");

await hubConnection.stop();

done();
} catch (err) {
fail(err);
done();
}
});
});
});

it("can reconnect after negotiate redirect", async (done) => {
try {
const reconnectingPromise = new PromiseSource();
const reconnectedPromise = new PromiseSource<string | undefined>();
const hubConnection = getConnectionBuilder(undefined, TESTHUB_REDIRECT_ENDPOINT_URL)
.withAutomaticReconnect()
.build();

hubConnection.onreconnecting(() => {
reconnectingPromise.resolve();
});

hubConnection.onreconnected((connectionId?) => {
reconnectedPromise.resolve(connectionId);
});

await hubConnection.start();

const preReconnectRedirects = await hubConnection.invoke<number>("GetNumRedirects");

const initialConnectionId = (hubConnection as any).connection.connectionId as string;

// Induce reconnect
(hubConnection as any).serverTimeout();

await reconnectingPromise;
const newConnectionId = await reconnectedPromise;

expect(newConnectionId).not.toBe(initialConnectionId);

const postReconnectRedirects = await hubConnection.invoke<number>("GetNumRedirects");

expect(postReconnectRedirects).toBeGreaterThan(preReconnectRedirects);

await hubConnection.stop();

done();
} catch (err) {
fail(err);
done();
}
});

it("can reconnect after skipping negotiation", async (done) => {
try {
const reconnectingPromise = new PromiseSource();
const reconnectedPromise = new PromiseSource<string | undefined>();
const hubConnection = getConnectionBuilder(
HttpTransportType.WebSockets,
undefined,
{ skipNegotiation: true },
)
.withAutomaticReconnect()
.build();

hubConnection.onreconnecting(() => {
reconnectingPromise.resolve();
});

hubConnection.onreconnected((connectionId?) => {
reconnectedPromise.resolve(connectionId);
});

await hubConnection.start();

// Induce reconnect
(hubConnection as any).serverTimeout();

await reconnectingPromise;
const newConnectionId = await reconnectedPromise;

expect(newConnectionId).toBeUndefined();

const response = await hubConnection.invoke("Echo", "test");

expect(response).toEqual("test");

await hubConnection.stop();

done();
} catch (err) {
fail(err);
done();
}
});

if (typeof EventSource !== "undefined") {
it("allows Server-Sent Events when negotiating for JSON protocol", async (done) => {
const hubConnection = getConnectionBuilder(undefined, TESTHUB_NOWEBSOCKETS_ENDPOINT_URL)
Expand Down
20 changes: 20 additions & 0 deletions src/SignalR/clients/ts/signalr/src/DefaultReconnectPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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 { IReconnectPolicy } from "./IReconnectPolicy";

// 0, 2, 10, 30 second delays before reconnect attempts.
const DEFAULT_RETRY_DELAYS_IN_MILLISECONDS = [0, 2000, 10000, 30000, null];

/** @private */
export class DefaultReconnectPolicy implements IReconnectPolicy {
private readonly retryDelays: Array<number | null>;

constructor(retryDelays?: number[]) {
this.retryDelays = retryDelays !== undefined ? [...retryDelays, null] : DEFAULT_RETRY_DELAYS_IN_MILLISECONDS;
}

public nextRetryDelayInMilliseconds(previousRetryCount: number): number | null {
return this.retryDelays[previousRetryCount];
}
}
Loading