From 056b537a4ac1fb5ba423ef3033ad27a36520df90 Mon Sep 17 00:00:00 2001 From: Rohan Rose Date: Sun, 18 Oct 2026 18:54:29 +0000 Subject: [PATCH 1/2] fix: close realtime connection on logout to prevent ghost sessions EmbeddedChatApi.logout() only cleared the auth token via auth.logout(). The DDP connection and its active subscriptions were left running, so the client kept receiving live messages and events for a session that was no longer authenticated. logout() now tears the connection down via the existing close() method. The teardown runs in a finally block so the socket is also closed when the logout request itself fails, and close() errors are logged rather than propagated so they cannot mask the logout result. Fixes #1158 --- packages/api/src/EmbeddedChatApi.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 6f2408414..8ff5f7043 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -231,6 +231,16 @@ export default class EmbeddedChatApi { } catch (err: any) { console.error(err instanceof Error ? err.message : err); return err; + } finally { + // Tear down the realtime connection even if the logout request + // failed, otherwise the socket stays subscribed to the room and + // keeps receiving messages for a session that is no longer + // authenticated. + try { + await this.close(); + } catch (err: any) { + console.error(err instanceof Error ? err.message : err); + } } } From dceb7938879ed712733f7dda38ed1ddef603fe1a Mon Sep 17 00:00:00 2001 From: Rohan Rose Date: Sun, 25 Oct 2026 17:56:05 +0000 Subject: [PATCH 2/2] fix: cancel in-flight connect on logout to prevent ghost subscriptions close() now invalidates a connect() which is still awaiting, so that subscriptions are not registered after the socket has been torn down. Previously, if logout happened while _doConnect() was awaiting joinRoom() (or the connection/login steps), the connect would resume afterwards and register the stream subscriptions on the closed connection, which the DDP client replays on the next connection. Addresses review feedback on #1394. --- packages/api/src/EmbeddedChatApi.ts | 31 +++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 8ff5f7043..05d62f780 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -22,6 +22,9 @@ export default class EmbeddedChatApi { auth: RocketChatAuth; private _connectPromise: Promise | null = null; private _activeSubscriptions: { stop: () => void }[] = []; + // Incremented by close() so that a connect() which is still awaiting + // can detect that it has been superseded or cancelled. + private _connectionGeneration = 0; private _authListener: ((user: any) => void) | null = null; constructor( @@ -283,16 +286,27 @@ export default class EmbeddedChatApi { } private async _doConnect(isChannelPrivate = false) { + // Take a snapshot of the generation: every close() invalidates it, so + // a connect() which is interrupted while awaiting (for example by a + // logout happening during joinRoom()) can stop before subscribing. + // Without this the subscriptions would be created after the socket + // was torn down, and the DDP client would replay them on reconnect. + this.close(); + const generation = this._connectionGeneration; + const isStale = () => generation !== this._connectionGeneration; try { - this.close(); // before connection, all previous subscriptions should be cancelled await this.sdk.connection.connect(); + if (isStale()) return; const currentUser = (await this.auth.getCurrentUser()) as any; + if (isStale()) return; const token = currentUser?.authToken || currentUser?.data?.authToken; if (token) { this._applyCredentials(currentUser); await this.sdk.account.loginWithToken(token); + if (isStale()) return; await this.joinRoom(isChannelPrivate); + if (isStale()) return; } // Subscribe to room messages @@ -369,6 +383,12 @@ export default class EmbeddedChatApi { ); this._activeSubscriptions.push(notifyUserUiSub); } + + // A close() may have happened while the subscriptions above were + // being registered, so drop them if this connect is no longer current. + if (isStale()) { + this._stopSubscriptions(); + } } catch (err) { this.close(); } @@ -561,9 +581,16 @@ export default class EmbeddedChatApi { } } - async close() { + private _stopSubscriptions() { this._activeSubscriptions.forEach((sub) => sub.stop()); this._activeSubscriptions = []; + } + + async close() { + // Invalidate any connect() which is currently in flight, so that it + // does not register subscriptions after this teardown. + this._connectionGeneration += 1; + this._stopSubscriptions(); this.sdk.connection.close(); }