From 5f2db7bd5cfdf57cdc04d6a6ed752f43e5b06657 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Tue, 3 Dec 2024 12:30:39 +0100 Subject: [PATCH 1/5] fix: handle timeout on SFU WS connections (#1600) ### Fixes hanging join requests when SFU WS connection cannot be established Previously, `signalReady` for SFU WS never rejected if WS connection couldn't be established. We now reject it after a timeout, so that the `call.join()` doesn't hang forever. We reuse the `joinTimeout` option as a sensible default here. ### Adds support for disconnection timeout setting When going through the reconnection flow, we now take into account the new `disconnectionTimeoutSeconds` setting, so we don't retry reconnecting indefinitely. --- packages/client/src/Call.ts | 41 +++++++++++++-- packages/client/src/StreamSfuClient.ts | 55 ++++++++++---------- packages/client/src/helpers/promise.ts | 44 ++++++++++++++++ packages/client/src/helpers/withResolvers.ts | 43 --------------- 4 files changed, 109 insertions(+), 74 deletions(-) delete mode 100644 packages/client/src/helpers/withResolvers.ts diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 57536d6d42..8d22dcdb9c 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -120,9 +120,10 @@ import { getSdkSignature } from './stats/utils'; import { withoutConcurrency } from './helpers/concurrency'; import { ensureExhausted } from './helpers/ensureExhausted'; import { + makeSafePromise, PromiseWithResolvers, promiseWithResolvers, -} from './helpers/withResolvers'; +} from './helpers/promise'; /** * An object representation of a `Call`. @@ -213,6 +214,7 @@ export class Call { private reconnectAttempts = 0; private reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED; private fastReconnectDeadlineSeconds: number = 0; + private disconnectionTimeoutSeconds: number = 0; private lastOfflineTimestamp: number = 0; private networkAvailableTask: PromiseWithResolvers | undefined; // maintain the order of publishing tracks to restore them after a reconnection @@ -1051,6 +1053,9 @@ export class Call { */ private handleSfuSignalClose = (sfuClient: StreamSfuClient) => { this.logger('debug', '[Reconnect] SFU signal connection closed'); + // SFU WS closed before we finished current join, no need to schedule reconnect + // because join operation will fail + if (this.state.callingState === CallingState.JOINING) return; // normal close, no need to reconnect if (sfuClient.isLeaving) return; this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => { @@ -1068,14 +1073,35 @@ export class Call { private reconnect = async ( strategy: WebsocketReconnectStrategy, ): Promise => { + if ( + this.state.callingState === CallingState.RECONNECTING || + this.state.callingState === CallingState.RECONNECTING_FAILED + ) + return; + return withoutConcurrency(this.reconnectConcurrencyTag, async () => { this.logger( 'info', `[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[strategy]}`, ); + let reconnectStartTime = Date.now(); this.reconnectStrategy = strategy; + do { + if ( + this.disconnectionTimeoutSeconds > 0 && + (Date.now() - reconnectStartTime) / 1000 > + this.disconnectionTimeoutSeconds + ) { + this.logger( + 'warn', + '[Reconnect] Stopping reconnection attempts after reaching disconnection timeout', + ); + this.state.setCallingState(CallingState.RECONNECTING_FAILED); + return; + } + // we don't increment reconnect attempts for the FAST strategy. if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) { this.reconnectAttempts++; @@ -1193,7 +1219,7 @@ export class Call { currentSubscriber?.detachEventHandlers(); currentPublisher?.detachEventHandlers(); - const migrationTask = currentSfuClient.enterMigration(); + const migrationTask = makeSafePromise(currentSfuClient.enterMigration()); try { const currentSfu = currentSfuClient.edgeName; @@ -1211,7 +1237,7 @@ export class Call { // Wait for the migration to complete, then close the previous SFU client // and the peer connection instances. In case of failure, the migration // task would throw an error and REJOIN would be attempted. - await migrationTask; + await migrationTask(); // in MIGRATE, we can consider the call as joined only after // `participantMigrationComplete` event is received, signaled by @@ -2337,4 +2363,13 @@ export class Call { ); this.dynascaleManager.applyTrackSubscriptions(); }; + + /** + * Sets the maximum amount of time a user can remain waiting for a reconnect + * after a network disruption + * @param timeoutSeconds Timeout in seconds, or 0 to keep reconnecting indefinetely + */ + setDisconnectionTimeout = (timeoutSeconds: number) => { + this.disconnectionTimeoutSeconds = timeoutSeconds; + }; } diff --git a/packages/client/src/StreamSfuClient.ts b/packages/client/src/StreamSfuClient.ts index eca72c36c4..40f80898e7 100644 --- a/packages/client/src/StreamSfuClient.ts +++ b/packages/client/src/StreamSfuClient.ts @@ -25,15 +25,16 @@ import { } from './gen/video/sfu/signal_rpc/signal'; import { ICETrickle, TrackType } from './gen/video/sfu/models/models'; import { StreamClient } from './coordinator/connection/client'; -import { generateUUIDv4, sleep } from './coordinator/connection/utils'; +import { generateUUIDv4 } from './coordinator/connection/utils'; import { Credentials } from './gen/coordinator'; import { Logger } from './coordinator/connection/types'; import { getLogger, getLogLevel } from './logger'; -import { withoutConcurrency } from './helpers/concurrency'; import { promiseWithResolvers, PromiseWithResolvers, -} from './helpers/withResolvers'; + makeSafePromise, + SafePromise, +} from './helpers/promise'; export type StreamSfuClientConstructor = { /** @@ -101,7 +102,7 @@ export class StreamSfuClient { /** * Promise that resolves when the WebSocket connection is ready (open). */ - private signalReady!: Promise; + private signalReady!: SafePromise; /** * Flag to indicate if the client is in the process of leaving the call. @@ -116,7 +117,6 @@ export class StreamSfuClient { private pingIntervalInMs = 10 * 1000; private unhealthyTimeoutInMs = this.pingIntervalInMs + 5 * 1000; private lastMessageTimestamp?: Date; - private readonly restoreWebSocketConcurrencyTag = Symbol('recoverWebSocket'); private readonly unsubscribeIceTrickle: () => void; private readonly unsubscribeNetworkChanged: () => void; private readonly onSignalClose: (() => void) | undefined; @@ -124,7 +124,7 @@ export class StreamSfuClient { private readonly logTag: string; private readonly credentials: Credentials; private readonly dispatcher: Dispatcher; - private readonly joinResponseTimeout?: number; + private readonly joinResponseTimeout: number; private networkAvailableTask: PromiseWithResolvers | undefined; /** * Promise that resolves when the JoinResponse is received. @@ -227,32 +227,31 @@ export class StreamSfuClient { }); this.signalWs.addEventListener('close', this.handleWebSocketClose); - this.signalWs.addEventListener('error', this.restoreWebSocket); - - this.signalReady = new Promise((resolve) => { - const onOpen = () => { - this.signalWs.removeEventListener('open', onOpen); - resolve(this.signalWs); - }; - this.signalWs.addEventListener('open', onOpen); - }); + + this.signalReady = makeSafePromise( + Promise.race([ + new Promise((resolve) => { + const onOpen = () => { + this.signalWs.removeEventListener('open', onOpen); + resolve(this.signalWs); + }; + this.signalWs.addEventListener('open', onOpen); + }), + + new Promise((resolve, reject) => { + setTimeout( + () => reject(new Error('SFU WS connection timed out')), + this.joinResponseTimeout, + ); + }), + ]), + ); }; private cleanUpWebSocket = () => { - this.signalWs.removeEventListener('error', this.restoreWebSocket); this.signalWs.removeEventListener('close', this.handleWebSocketClose); }; - private restoreWebSocket = () => { - withoutConcurrency(this.restoreWebSocketConcurrencyTag, async () => { - await this.networkAvailableTask?.promise; - this.logger('debug', 'Restoring SFU WS connection'); - this.cleanUpWebSocket(); - await sleep(500); - this.createWebSocket(); - }).catch((err) => this.logger('debug', `Can't restore WS connection`, err)); - }; - get isHealthy() { return this.signalWs.readyState === WebSocket.OPEN; } @@ -409,7 +408,7 @@ export class StreamSfuClient { data: Omit, ): Promise => { // wait for the signal web socket to be ready before sending "joinRequest" - await this.signalReady; + await this.signalReady(); if (this.joinResponseTask.isResolved || this.joinResponseTask.isRejected) { // we need to lock the RPC requests until we receive a JoinResponse. // that's why we have this primitive lock mechanism. @@ -478,7 +477,7 @@ export class StreamSfuClient { }; private send = async (message: SfuRequest) => { - await this.signalReady; // wait for the signal ws to be open + await this.signalReady(); // wait for the signal ws to be open const msgJson = SfuRequest.toJson(message); if (this.signalWs.readyState !== WebSocket.OPEN) { this.logger('debug', 'Signal WS is not open. Skipping message', msgJson); diff --git a/packages/client/src/helpers/promise.ts b/packages/client/src/helpers/promise.ts index 482bc7c07a..f607ce5058 100644 --- a/packages/client/src/helpers/promise.ts +++ b/packages/client/src/helpers/promise.ts @@ -45,3 +45,47 @@ export function makeSafePromise(promise: Promise): SafePromise { unwrapPromise.checkPending = () => isPending; return unwrapPromise; } + +export type PromiseWithResolvers = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason: any) => void; + isResolved: boolean; + isRejected: boolean; +}; + +/** + * Creates a new promise with resolvers. + * + * Based on: + * - https://github.com/tc39/proposal-promise-with-resolvers/blob/main/polyfills.js + */ +export const promiseWithResolvers = (): PromiseWithResolvers => { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason: any) => void; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + let isResolved = false; + let isRejected = false; + + const resolver = (value: T | PromiseLike) => { + isResolved = true; + resolve(value); + }; + + const rejecter = (reason: any) => { + isRejected = true; + reject(reason); + }; + + return { + promise, + resolve: resolver, + reject: rejecter, + isResolved, + isRejected, + }; +}; diff --git a/packages/client/src/helpers/withResolvers.ts b/packages/client/src/helpers/withResolvers.ts deleted file mode 100644 index dde8182242..0000000000 --- a/packages/client/src/helpers/withResolvers.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type PromiseWithResolvers = { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - reject: (reason: any) => void; - isResolved: boolean; - isRejected: boolean; -}; - -/** - * Creates a new promise with resolvers. - * - * Based on: - * - https://github.com/tc39/proposal-promise-with-resolvers/blob/main/polyfills.js - */ -export const promiseWithResolvers = (): PromiseWithResolvers => { - let resolve: (value: T | PromiseLike) => void; - let reject: (reason: any) => void; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - - let isResolved = false; - let isRejected = false; - - const resolver = (value: T | PromiseLike) => { - isResolved = true; - resolve(value); - }; - - const rejecter = (reason: any) => { - isRejected = true; - reject(reason); - }; - - return { - promise, - resolve: resolver, - reject: rejecter, - isResolved, - isRejected, - }; -}; From c8e7254cd7d5f7ae9054b81b0a68c61c1f97c22b Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Tue, 3 Dec 2024 11:33:33 +0000 Subject: [PATCH 2/5] chore(@stream-io/video-client): release version 1.11.12 --- packages/client/CHANGELOG.md | 7 +++++++ packages/client/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index cc8ded470f..8d1a9c5c1b 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -2,6 +2,13 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.11.12](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.11.11...@stream-io/video-client-1.11.12) (2024-12-03) + + +### Bug Fixes + +* handle timeout on SFU WS connections ([#1600](https://github.com/GetStream/stream-video-js/issues/1600)) ([5f2db7b](https://github.com/GetStream/stream-video-js/commit/5f2db7bd5cfdf57cdc04d6a6ed752f43e5b06657)) + ## [1.11.11](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.11.10...@stream-io/video-client-1.11.11) (2024-11-29) diff --git a/packages/client/package.json b/packages/client/package.json index 55d64defcd..410ae0d6ba 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-client", - "version": "1.11.11", + "version": "1.11.12", "packageManager": "yarn@3.2.4", "main": "dist/index.cjs.js", "module": "dist/index.es.js", From 55896f3a9996e0eb4dcbaaebb3f2c48a062486ce Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Tue, 3 Dec 2024 11:33:56 +0000 Subject: [PATCH 3/5] chore(@stream-io/video-react-bindings): release version 1.2.6 --- packages/react-bindings/CHANGELOG.md | 5 +++++ packages/react-bindings/package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-bindings/CHANGELOG.md b/packages/react-bindings/CHANGELOG.md index a9e488b2b8..0c1c26c4f5 100644 --- a/packages/react-bindings/CHANGELOG.md +++ b/packages/react-bindings/CHANGELOG.md @@ -2,6 +2,11 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.2.6](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-bindings-1.2.5...@stream-io/video-react-bindings-1.2.6) (2024-12-03) + +### Dependency Updates + +* `@stream-io/video-client` updated to version `1.11.12` ## [1.2.5](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-bindings-1.2.4...@stream-io/video-react-bindings-1.2.5) (2024-11-29) ### Dependency Updates diff --git a/packages/react-bindings/package.json b/packages/react-bindings/package.json index 7b62e12a4c..d879e1fb5e 100644 --- a/packages/react-bindings/package.json +++ b/packages/react-bindings/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-bindings", - "version": "1.2.5", + "version": "1.2.6", "packageManager": "yarn@3.2.4", "main": "./dist/index.cjs.js", "module": "./dist/index.es.js", From 4bbd158497dcf4d18e50f1f257330f1b22b1b008 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Tue, 3 Dec 2024 11:34:04 +0000 Subject: [PATCH 4/5] chore(@stream-io/video-react-sdk): release version 1.7.28 --- packages/react-sdk/CHANGELOG.md | 6 ++++++ packages/react-sdk/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-sdk/CHANGELOG.md b/packages/react-sdk/CHANGELOG.md index 317d18e33e..46efb26806 100644 --- a/packages/react-sdk/CHANGELOG.md +++ b/packages/react-sdk/CHANGELOG.md @@ -2,6 +2,12 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.7.28](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-sdk-1.7.27...@stream-io/video-react-sdk-1.7.28) (2024-12-03) + +### Dependency Updates + +* `@stream-io/video-client` updated to version `1.11.12` +* `@stream-io/video-react-bindings` updated to version `1.2.6` ## [1.7.27](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-sdk-1.7.26...@stream-io/video-react-sdk-1.7.27) (2024-11-29) ### Dependency Updates diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index d8223bc5be..2845ee993b 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-sdk", - "version": "1.7.27", + "version": "1.7.28", "packageManager": "yarn@3.2.4", "main": "./dist/index.cjs.js", "module": "./dist/index.es.js", From 2695bab0761166515b53b7498f6491a8f5f05153 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Tue, 3 Dec 2024 11:34:21 +0000 Subject: [PATCH 5/5] chore(@stream-io/video-react-native-sdk): release version 1.4.8 --- packages/react-native-sdk/CHANGELOG.md | 6 ++++++ packages/react-native-sdk/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-native-sdk/CHANGELOG.md b/packages/react-native-sdk/CHANGELOG.md index ca6d93abc3..a15eb940aa 100644 --- a/packages/react-native-sdk/CHANGELOG.md +++ b/packages/react-native-sdk/CHANGELOG.md @@ -2,6 +2,12 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +## [1.4.8](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-1.4.7...@stream-io/video-react-native-sdk-1.4.8) (2024-12-03) + +### Dependency Updates + +* `@stream-io/video-client` updated to version `1.11.12` +* `@stream-io/video-react-bindings` updated to version `1.2.6` ## [1.4.7](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-react-native-sdk-1.4.6...@stream-io/video-react-native-sdk-1.4.7) (2024-11-29) ### Dependency Updates diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index a6dd6d3df9..144d5de8cb 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/video-react-native-sdk", - "version": "1.4.7", + "version": "1.4.8", "packageManager": "yarn@3.2.4", "main": "dist/commonjs/index.js", "module": "dist/module/index.js",