diff --git a/.changeset/fix-concurrent-logout.md b/.changeset/fix-concurrent-logout.md new file mode 100644 index 0000000000..0d3f1f8447 --- /dev/null +++ b/.changeset/fix-concurrent-logout.md @@ -0,0 +1,6 @@ +--- +"jazz-tools": patch +--- + +Bugfix: fixed an issue where calling logOut multiple times concurrently could trigger duplicate logout operations + diff --git a/CHANGELOG.md b/CHANGELOG.md index c29d22c172..493760bc45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +Released Jazz 0.19.17: +- Bugfix: fixed an issue where calling logOut multiple times concurrently could trigger duplicate logout operations + Released Jazz 0.19.16: - Improved sync timeout error messages to include known state, peer state, and any error information when waiting for sync times out - Bugfix: fixed a race condition in Clerk auth where the signup flow could trigger a duplicate login attempt diff --git a/packages/jazz-tools/src/react-native-core/ReactNativeSessionProvider.ts b/packages/jazz-tools/src/react-native-core/ReactNativeSessionProvider.ts index f24da851d6..3981669845 100644 --- a/packages/jazz-tools/src/react-native-core/ReactNativeSessionProvider.ts +++ b/packages/jazz-tools/src/react-native-core/ReactNativeSessionProvider.ts @@ -16,6 +16,23 @@ export class ReactNativeSessionProvider implements SessionProvider { const kvStore = KvStoreContext.getInstance().getStorage(); const existingSession = await kvStore.get(accountID as string); + if (!existingSession) { + const newSessionID = crypto.newRandomSessionID( + accountID as RawAccountID | AgentID, + ); + await kvStore.set(accountID, newSessionID); + lockedSessions.add(newSessionID); + + console.log("Created new session", newSessionID); + + return Promise.resolve({ + sessionID: newSessionID, + sessionDone: () => { + lockedSessions.delete(newSessionID); + }, + }); + } + // Check if the session is already in use, should happen only if the dev // mounts multiple providers at the same time if (lockedSessions.has(existingSession as SessionID)) { @@ -31,33 +48,13 @@ export class ReactNativeSessionProvider implements SessionProvider { }); } - if (existingSession) { - console.log("Using existing session", existingSession); - lockedSessions.add(existingSession as SessionID); - return Promise.resolve({ - sessionID: existingSession as SessionID, - sessionDone: () => { - lockedSessions.delete(existingSession as SessionID); - }, - }); - } - - // We need to provide this for backwards compatibility with the old session provider - // With the current session provider we should never get here because: - // - New accounts provide their session and go through the persistSession method - // - Existing accounts should already have a session - const newSessionID = crypto.newRandomSessionID( - accountID as RawAccountID | AgentID, - ); - await kvStore.set(accountID, newSessionID); - lockedSessions.add(newSessionID); - - console.error("Created new session", newSessionID); + console.log("Using existing session", existingSession); + lockedSessions.add(existingSession as SessionID); return Promise.resolve({ - sessionID: newSessionID, + sessionID: existingSession as SessionID, sessionDone: () => { - lockedSessions.delete(newSessionID); + lockedSessions.delete(existingSession as SessionID); }, }); } @@ -69,6 +66,9 @@ export class ReactNativeSessionProvider implements SessionProvider { const kvStore = KvStoreContext.getInstance().getStorage(); await kvStore.set(accountID, sessionID); lockedSessions.add(sessionID); + + console.log("Persisted session", sessionID); + return Promise.resolve({ sessionDone: () => { lockedSessions.delete(sessionID); diff --git a/packages/jazz-tools/src/tools/implementation/ContextManager.ts b/packages/jazz-tools/src/tools/implementation/ContextManager.ts index b66fc6e7dc..7eade7b9d8 100644 --- a/packages/jazz-tools/src/tools/implementation/ContextManager.ts +++ b/packages/jazz-tools/src/tools/implementation/ContextManager.ts @@ -188,24 +188,45 @@ export class JazzContextManager< return this.subscriptionCache; } + /** + * Flag to indicate if a logout operation is currently in progress. + * Used to prevent concurrent logout attempts or double-logout issues. + * Set to true when logout starts, reset to false once all logout logic runs. + */ + loggingOut = false; + + /** + * Handles the logout process. + * Uses the loggingOut flag to ensure only one logout can happen at a time. + */ logOut = async () => { - if (!this.context || !this.props) { + if (!this.context || !this.props || this.loggingOut) { return; } + // Mark as logging out to prevent reentry + this.loggingOut = true; + this.authenticatingAccountID = null; // Clear cache on logout to prevent subscription leaks across authentication boundaries this.subscriptionCache.clear(); - await this.props.onLogOut?.(); + try { + await this.props.onLogOut?.(); - if (this.props.logOutReplacement) { - await this.props.logOutReplacement(); - } else { - await this.context.logOut(); - return this.createContext(this.props); + if (this.props.logOutReplacement) { + await this.props.logOutReplacement(); + } else { + await this.context.logOut(); + await this.createContext(this.props); + } + } catch (error) { + console.error("Error during logout", error); } + + // Reset flag after standard logout finishes + this.loggingOut = false; }; done = () => { diff --git a/packages/jazz-tools/src/tools/tests/ContextManager.test.ts b/packages/jazz-tools/src/tools/tests/ContextManager.test.ts index 10295ad422..7f3bb808e7 100644 --- a/packages/jazz-tools/src/tools/tests/ContextManager.test.ts +++ b/packages/jazz-tools/src/tools/tests/ContextManager.test.ts @@ -604,6 +604,22 @@ describe("ContextManager", () => { expect(onAnonymousAccountDiscarded).toHaveBeenCalledTimes(1); }); + test("prevents concurrent logout attempts", async () => { + const onLogOut = vi.fn(); + await manager.createContext({ onLogOut }); + + // Start multiple concurrent logout attempts + const promises = []; + for (let i = 0; i < 5; i++) { + promises.push(manager.logOut()); + } + + await Promise.all(promises); + + // onLogOut should only be called once despite multiple logOut calls + expect(onLogOut).toHaveBeenCalledTimes(1); + }); + test("allows authentication after logout", async () => { const account = await createJazzTestAccount(); const onAnonymousAccountDiscarded = vi.fn();