Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-concurrent-logout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"jazz-tools": patch
---

Bugfix: fixed an issue where calling logOut multiple times concurrently could trigger duplicate logout operations

3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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);
},
});
}
Expand All @@ -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);
Expand Down
35 changes: 28 additions & 7 deletions packages/jazz-tools/src/tools/implementation/ContextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/jazz-tools/src/tools/tests/ContextManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading