Skip to content

Commit 5d8208b

Browse files
[SignalR TS] Fix permanent Disconnecting state (#30948)
1 parent 7461631 commit 5d8208b

File tree

5 files changed

+169
-9
lines changed

5 files changed

+169
-9
lines changed

src/SignalR/clients/ts/signalr/src/HttpConnection.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export class HttpConnection implements IConnection {
5252
private transport?: ITransport;
5353
private _startInternalPromise?: Promise<void>;
5454
private _stopPromise?: Promise<void>;
55-
private _stopPromiseResolver!: (value?: PromiseLike<void>) => void;
55+
private _stopPromiseResolver: (value?: PromiseLike<void>) => void = () => {};
5656
private _stopError?: Error;
5757
private _accessTokenFactory?: () => string | Promise<string>;
5858
private _sendQueue?: TransportSendQueue;
@@ -215,7 +215,6 @@ export class HttpConnection implements IConnection {
215215
this.transport = undefined;
216216
} else {
217217
this._logger.log(LogLevel.Debug, "HttpConnection.transport is undefined in HttpConnection.stop() because start() failed.");
218-
this._stopConnection();
219218
}
220219
}
221220

@@ -295,6 +294,9 @@ export class HttpConnection implements IConnection {
295294
this._logger.log(LogLevel.Error, "Failed to start the connection: " + e);
296295
this._connectionState = ConnectionState.Disconnected;
297296
this.transport = undefined;
297+
298+
// if start fails, any active calls to stop assume that start will complete the stop promise
299+
this._stopPromiseResolver();
298300
return Promise.reject(e);
299301
}
300302
}

src/SignalR/clients/ts/signalr/src/HubConnection.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,11 @@ export class HubConnection {
768768
this._logger.log(LogLevel.Information, `Reconnect attempt failed because of error '${e}'.`);
769769

770770
if (this._connectionState !== HubConnectionState.Reconnecting) {
771-
this._logger.log(LogLevel.Debug, "Connection left the reconnecting state during reconnect attempt. Done reconnecting.");
771+
this._logger.log(LogLevel.Debug, `Connection moved to the '${this._connectionState}' from the reconnecting state during reconnect attempt. Done reconnecting.`);
772+
// The TypeScript compiler thinks that connectionState must be Connected here. The TypeScript compiler is wrong.
773+
if (this._connectionState as any === HubConnectionState.Disconnecting) {
774+
this._completeClose();
775+
}
772776
return;
773777
}
774778

src/SignalR/clients/ts/signalr/tests/HttpConnection.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,23 +137,30 @@ describe("HttpConnection", () => {
137137

138138
it("can stop a starting connection", async () => {
139139
await VerifyLogger.run(async (logger) => {
140+
const stoppingPromise = new PromiseSource();
141+
const startingPromise = new PromiseSource();
140142
const options: IHttpConnectionOptions = {
141143
...commonOptions,
142144
httpClient: new TestHttpClient()
143145
.on("POST", async () => {
144-
await connection.stop();
146+
startingPromise.resolve();
147+
await stoppingPromise;
145148
return "{}";
146-
})
147-
.on("GET", async () => {
148-
await connection.stop();
149-
return "";
150149
}),
151150
logger,
152151
} as IHttpConnectionOptions;
153152

154153
const connection = new HttpConnection("http://tempuri.org", options);
155154

156-
await expect(connection.start(TransferFormat.Text))
155+
const startPromise = connection.start(TransferFormat.Text)
156+
157+
await startingPromise;
158+
const stopPromise = connection.stop();
159+
stoppingPromise.resolve();
160+
161+
await stopPromise;
162+
163+
await expect(startPromise)
157164
.rejects
158165
.toThrow("The connection was stopped during negotiation.");
159166
},

src/SignalR/clients/ts/signalr/tests/HubConnection.Reconnect.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
import { DefaultReconnectPolicy } from "../src/DefaultReconnectPolicy";
5+
import { HttpConnection, INegotiateResponse } from "../src/HttpConnection";
56
import { HubConnection, HubConnectionState } from "../src/HubConnection";
7+
import { IHttpConnectionOptions } from "../src/IHttpConnectionOptions";
68
import { MessageType } from "../src/IHubProtocol";
79
import { RetryContext } from "../src/IRetryPolicy";
810
import { JsonHubProtocol } from "../src/JsonHubProtocol";
911

1012
import { VerifyLogger } from "./Common";
1113
import { TestConnection } from "./TestConnection";
1214
import { PromiseSource } from "./Utils";
15+
import { TestHttpClient } from "./TestHttpClient";
16+
import { TestWebSocket, TestEvent, TestMessageEvent } from "./TestWebSocket";
1317

1418
describe("auto reconnect", () => {
1519
it("is not enabled by default", async () => {
@@ -785,4 +789,93 @@ describe("auto reconnect", () => {
785789
}
786790
});
787791
});
792+
793+
it("can be stopped while restarting the underlying connection and negotiate throws", async () => {
794+
await VerifyLogger.run(async (logger) => {
795+
let onreconnectingCount = 0;
796+
let onreconnectedCount = 0;
797+
let closeCount = 0;
798+
799+
const nextRetryDelayCalledPromise = new PromiseSource();
800+
801+
const defaultConnectionId = "abc123";
802+
const defaultConnectionToken = "123abc";
803+
const defaultNegotiateResponse: INegotiateResponse = {
804+
availableTransports: [
805+
{ transport: "WebSockets", transferFormats: ["Text", "Binary"] },
806+
{ transport: "ServerSentEvents", transferFormats: ["Text"] },
807+
{ transport: "LongPolling", transferFormats: ["Text", "Binary"] },
808+
],
809+
connectionId: defaultConnectionId,
810+
connectionToken: defaultConnectionToken,
811+
negotiateVersion: 1,
812+
};
813+
814+
const startStarted = new PromiseSource();
815+
let negotiateCount = 0;
816+
817+
const options: IHttpConnectionOptions = {
818+
WebSocket: TestWebSocket,
819+
httpClient: new TestHttpClient()
820+
.on("POST", async () => {
821+
++negotiateCount;
822+
if (negotiateCount === 1) {
823+
return defaultNegotiateResponse;
824+
}
825+
startStarted.resolve();
826+
return Promise.reject("Error with negotiate");
827+
})
828+
.on("GET", () => ""),
829+
logger,
830+
} as IHttpConnectionOptions;
831+
832+
const connection = new HttpConnection("http://tempuri.org", options);
833+
const hubConnection = HubConnection.create(connection, logger, new JsonHubProtocol(), {
834+
nextRetryDelayInMilliseconds() {
835+
nextRetryDelayCalledPromise.resolve();
836+
return 0;
837+
},
838+
});
839+
840+
hubConnection.onreconnecting(() => {
841+
onreconnectingCount++;
842+
});
843+
844+
hubConnection.onreconnected(() => {
845+
onreconnectedCount++;
846+
});
847+
848+
hubConnection.onclose(() => {
849+
closeCount++;
850+
});
851+
852+
TestWebSocket.webSocketSet = new PromiseSource();
853+
const startPromise = hubConnection.start();
854+
await TestWebSocket.webSocketSet;
855+
await TestWebSocket.webSocket.openSet;
856+
TestWebSocket.webSocket.onopen(new TestEvent());
857+
TestWebSocket.webSocket.onmessage(new TestMessageEvent("{}\x1e"));
858+
859+
await startPromise;
860+
TestWebSocket.webSocket.close();
861+
TestWebSocket.webSocketSet = new PromiseSource();
862+
863+
await nextRetryDelayCalledPromise;
864+
865+
expect(hubConnection.state).toBe(HubConnectionState.Reconnecting);
866+
expect(onreconnectingCount).toBe(1);
867+
expect(onreconnectedCount).toBe(0);
868+
expect(closeCount).toBe(0);
869+
870+
await startStarted;
871+
await hubConnection.stop();
872+
873+
expect(hubConnection.state).toBe(HubConnectionState.Disconnected);
874+
expect(onreconnectingCount).toBe(1);
875+
expect(onreconnectedCount).toBe(0);
876+
expect(closeCount).toBe(1);
877+
},
878+
"Failed to complete negotiation with the server: Error with negotiate",
879+
"Failed to start the connection: Error with negotiate");
880+
});
788881
});

src/SignalR/clients/ts/signalr/tests/TestWebSocket.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,57 @@ export class TestCloseEvent implements Event {
231231
public CAPTURING_PHASE: number = 0;
232232
public NONE: number = 0;
233233
}
234+
235+
export class TestMessageEvent implements MessageEvent {
236+
constructor(data: any) {
237+
this.data = data;
238+
}
239+
public data: any;
240+
public lastEventId: string = "";
241+
public origin: string = "";
242+
public ports: readonly MessagePort[] = [];
243+
public source: MessagePort | Window | ServiceWorker | null = null;
244+
public composed: boolean = false;
245+
public composedPath(): EventTarget[];
246+
public composedPath(): any[] {
247+
throw new Error("Method not implemented.");
248+
}
249+
public code: number = 0;
250+
public reason: string = "";
251+
public wasClean: boolean = false;
252+
public initCloseEvent(typeArg: string, canBubbleArg: boolean, cancelableArg: boolean, wasCleanArg: boolean, codeArg: number, reasonArg: string): void {
253+
throw new Error("Method not implemented.");
254+
}
255+
public bubbles: boolean = false;
256+
public cancelBubble: boolean = false;
257+
public cancelable: boolean = false;
258+
public currentTarget!: EventTarget;
259+
public defaultPrevented: boolean = false;
260+
public eventPhase: number = 0;
261+
public isTrusted: boolean = false;
262+
public returnValue: boolean = false;
263+
public scoped: boolean = false;
264+
public srcElement!: Element | null;
265+
public target!: EventTarget;
266+
public timeStamp: number = 0;
267+
public type: string = "";
268+
public deepPath(): EventTarget[] {
269+
throw new Error("Method not implemented.");
270+
}
271+
public initEvent(type: string, bubbles?: boolean | undefined, cancelable?: boolean | undefined): void {
272+
throw new Error("Method not implemented.");
273+
}
274+
public preventDefault(): void {
275+
throw new Error("Method not implemented.");
276+
}
277+
public stopImmediatePropagation(): void {
278+
throw new Error("Method not implemented.");
279+
}
280+
public stopPropagation(): void {
281+
throw new Error("Method not implemented.");
282+
}
283+
public AT_TARGET: number = 0;
284+
public BUBBLING_PHASE: number = 0;
285+
public CAPTURING_PHASE: number = 0;
286+
public NONE: number = 0;
287+
}

0 commit comments

Comments
 (0)