From c694bae5ac04b5c1bceac877dd54c5061ad12751 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 24 Nov 2025 16:47:17 -0600 Subject: [PATCH 01/11] fix(clerk-js): Correct race condition when fetching tokens --- .../fix-token-refresh-race-condition.md | 6 ++++ .../src/core/auth/AuthCookieService.ts | 32 +++++++++++++------ .../src/core/auth/SessionCookiePoller.ts | 9 ++++-- packages/clerk-js/src/core/auth/safeLock.ts | 6 +++- 4 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-token-refresh-race-condition.md diff --git a/.changeset/fix-token-refresh-race-condition.md b/.changeset/fix-token-refresh-race-condition.md new file mode 100644 index 00000000000..527be0d877d --- /dev/null +++ b/.changeset/fix-token-refresh-race-condition.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +--- + +Fix race condition where multiple browser tabs could fetch session tokens simultaneously. The `refreshTokenOnFocus` handler now uses the same cross-tab lock as the session token poller, preventing duplicate API calls when switching between tabs or when focus events fire while another tab is already refreshing the token. + diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 51268dc6bcd..f596c2cf3ba 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -20,7 +20,9 @@ import { createSessionCookie } from './cookies/session'; import { getCookieSuffix } from './cookieSuffix'; import type { DevBrowser } from './devBrowser'; import { createDevBrowser } from './devBrowser'; -import { SessionCookiePoller } from './SessionCookiePoller'; +import type { SafeLockReturn } from './safeLock'; +import { SafeLock } from './safeLock'; +import { REFRESH_SESSION_TOKEN_LOCK_KEY, SessionCookiePoller } from './SessionCookiePoller'; // TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller // and we need to avoid updating them concurrently. @@ -41,11 +43,12 @@ import { SessionCookiePoller } from './SessionCookiePoller'; * - handleUnauthenticatedDevBrowser(): resets dev browser in case of invalid dev browser */ export class AuthCookieService { - private poller: SessionCookiePoller | null = null; - private clientUat: ClientUatCookieHandler; - private sessionCookie: SessionCookieHandler; private activeCookie: ReturnType; + private clientUat: ClientUatCookieHandler; private devBrowser: DevBrowser; + private poller: SessionCookiePoller | null = null; + private sessionCookie: SessionCookieHandler; + private tokenRefreshLock: SafeLockReturn; public static async create( clerk: Clerk, @@ -66,6 +69,11 @@ export class AuthCookieService { private instanceType: InstanceType, private clerkEventBus: ReturnType, ) { + // Create shared lock for cross-tab token refresh coordination. + // This lock is used by both the poller and the focus handler to prevent + // concurrent token fetches across tabs. + this.tokenRefreshLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + // set cookie on token update eventBus.on(events.TokenUpdate, ({ token }) => { this.updateSessionCookie(token && token.getRawString()); @@ -77,14 +85,14 @@ export class AuthCookieService { this.refreshTokenOnFocus(); this.startPollingForToken(); - this.clientUat = createClientUatCookie(cookieSuffix); - this.sessionCookie = createSessionCookie(cookieSuffix); this.activeCookie = createActiveContextCookie(); + this.clientUat = createClientUatCookie(cookieSuffix); this.devBrowser = createDevBrowser({ - frontendApi: clerk.frontendApi, - fapiClient, cookieSuffix, + fapiClient, + frontendApi: clerk.frontendApi, }); + this.sessionCookie = createSessionCookie(cookieSuffix); } public async setup() { @@ -126,7 +134,7 @@ export class AuthCookieService { public startPollingForToken() { if (!this.poller) { - this.poller = new SessionCookiePoller(); + this.poller = new SessionCookiePoller(this.tokenRefreshLock); this.poller.startPollingForSessionToken(() => this.refreshSessionToken()); } } @@ -147,7 +155,11 @@ export class AuthCookieService { // is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie // is updated too late and not guaranteed to be fresh before the refetch occurs. // While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break. - void this.refreshSessionToken({ updateCookieImmediately: true }); + // + // We use the shared lock to coordinate with the poller and other tabs, preventing + // concurrent token fetches when multiple tabs become visible or when focus events + // fire while the poller is already refreshing the token. + void this.tokenRefreshLock.acquireLockAndRun(() => this.refreshSessionToken({ updateCookieImmediately: true })); } }); } diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index 91e8040f79d..bdc2b921f31 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -1,17 +1,22 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; +import type { SafeLockReturn } from './safeLock'; import { SafeLock } from './safeLock'; -const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; +export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; const INTERVAL_IN_MS = 5 * 1_000; export class SessionCookiePoller { - private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + private lock: SafeLockReturn; private workerTimers = createWorkerTimers(); private timerId: ReturnType | null = null; // Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed. private initiated = false; + constructor(lock?: SafeLockReturn) { + this.lock = lock ?? SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + } + public startPollingForSessionToken(cb: () => Promise): void { if (this.timerId || this.initiated) { return; diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index 405190a73ff..d28eb9a0657 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -1,6 +1,10 @@ import Lock from 'browser-tabs-lock'; -export function SafeLock(key: string) { +export interface SafeLockReturn { + acquireLockAndRun: (cb: () => Promise) => Promise; +} + +export function SafeLock(key: string): SafeLockReturn { const lock = new Lock(); // TODO: Figure out how to fix this linting error From b991f2900761e0aac4e54331786298ce3cf67e55 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 24 Nov 2025 19:56:31 -0600 Subject: [PATCH 02/11] TSDoc and tests --- .../src/core/auth/AuthCookieService.ts | 3 + .../src/core/auth/SessionCookiePoller.ts | 18 +++ .../__tests__/SessionCookiePoller.test.ts | 146 ++++++++++++++++++ .../src/core/auth/__tests__/safeLock.test.ts | 106 +++++++++++++ packages/clerk-js/src/core/auth/safeLock.ts | 40 ++++- 5 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts create mode 100644 packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index f596c2cf3ba..575b8e1d289 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -48,6 +48,9 @@ export class AuthCookieService { private devBrowser: DevBrowser; private poller: SessionCookiePoller | null = null; private sessionCookie: SessionCookieHandler; + /** + * Shared lock for coordinating token refresh operations across tabs + */ private tokenRefreshLock: SafeLockReturn; public static async create( diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index bdc2b921f31..a4c874ed56c 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -6,6 +6,24 @@ import { SafeLock } from './safeLock'; export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; const INTERVAL_IN_MS = 5 * 1_000; +/** + * Polls for session token refresh at regular intervals with cross-tab coordination. + * + * @example + * ```typescript + * // Create a shared lock for coordination with focus handlers + * const sharedLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + * + * // Poller uses the shared lock + * const poller = new SessionCookiePoller(sharedLock); + * poller.startPollingForSessionToken(() => refreshToken()); + * + * // Focus handler can use the same lock to prevent races + * window.addEventListener('focus', () => { + * sharedLock.acquireLockAndRun(() => refreshToken()); + * }); + * ``` + */ export class SessionCookiePoller { private lock: SafeLockReturn; private workerTimers = createWorkerTimers(); diff --git a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts new file mode 100644 index 00000000000..c02258b026c --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SafeLockReturn } from '../safeLock'; +import { SessionCookiePoller } from '../SessionCookiePoller'; + +describe('SessionCookiePoller', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('shared lock coordination', () => { + it('accepts an external lock for coordination with other components', () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + + // Verify the shared lock is used + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + + poller.stopPollingForSessionToken(); + }); + + it('creates internal lock when none provided (backward compatible)', () => { + // Should not throw when no lock is provided + const poller = new SessionCookiePoller(); + expect(poller).toBeInstanceOf(SessionCookiePoller); + }); + + it('enables focus handler and poller to share the same lock', () => { + // This test demonstrates the shared lock pattern used in AuthCookieService + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { + return cb(); + }), + }; + + const poller = new SessionCookiePoller(sharedLock); + const pollerCallback = vi.fn().mockResolvedValue('poller-result'); + + // Poller uses the shared lock + poller.startPollingForSessionToken(pollerCallback); + + // Simulate focus handler also using the shared lock (like AuthCookieService does) + const focusCallback = vi.fn().mockResolvedValue('focus-result'); + void sharedLock.acquireLockAndRun(focusCallback); + + // Both should use the same lock instance + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(pollerCallback); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(focusCallback); + + poller.stopPollingForSessionToken(); + }); + }); + + describe('startPollingForSessionToken', () => { + it('executes callback immediately on start', () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + + poller.stopPollingForSessionToken(); + }); + + it('prevents multiple concurrent polling sessions', () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + poller.startPollingForSessionToken(callback); // Second call should be ignored + + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + + poller.stopPollingForSessionToken(); + }); + }); + + describe('stopPollingForSessionToken', () => { + it('allows restart after stop', async () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + // Start and stop + poller.startPollingForSessionToken(callback); + poller.stopPollingForSessionToken(); + + // Clear mock to check restart + vi.mocked(sharedLock.acquireLockAndRun).mockClear(); + + // Should be able to start again + poller.startPollingForSessionToken(callback); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + + poller.stopPollingForSessionToken(); + }); + }); + + describe('polling interval', () => { + it('schedules next poll after callback completes', async () => { + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue(undefined), + }; + + const poller = new SessionCookiePoller(sharedLock); + const callback = vi.fn().mockResolvedValue(undefined); + + poller.startPollingForSessionToken(callback); + + // Initial call + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + + // Wait for first interval (5 seconds) + await vi.advanceTimersByTimeAsync(5000); + + // Should have scheduled another call + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + + poller.stopPollingForSessionToken(); + }); + }); +}); diff --git a/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts new file mode 100644 index 00000000000..e78d6ba9b19 --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { SafeLockReturn } from '../safeLock'; +import { SafeLock } from '../safeLock'; + +describe('SafeLock', () => { + describe('interface contract', () => { + it('returns SafeLockReturn interface with acquireLockAndRun method', () => { + const lock = SafeLock('test-interface'); + + expect(lock).toHaveProperty('acquireLockAndRun'); + expect(typeof lock.acquireLockAndRun).toBe('function'); + }); + + it('SafeLockReturn type allows creating mock implementations', () => { + // This test verifies the type interface works correctly for mocking + const mockLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockResolvedValue('mock-result'), + }; + + expect(mockLock.acquireLockAndRun).toBeDefined(); + }); + }); + + describe('Web Locks API path', () => { + it('uses Web Locks API when available in secure context', async () => { + // Skip if Web Locks not available (like in jsdom without polyfill) + if (!('locks' in navigator) || !navigator.locks) { + return; + } + + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const lock = SafeLock('test-weblocks-' + Date.now()); + const callback = vi.fn().mockResolvedValue('web-locks-result'); + + const result = await lock.acquireLockAndRun(callback); + + expect(callback).toHaveBeenCalled(); + expect(result).toBe('web-locks-result'); + // Verify cleanup happened + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); + }); + + describe('shared lock pattern', () => { + it('allows multiple components to share a lock via SafeLockReturn interface', async () => { + // This demonstrates how AuthCookieService shares a lock between poller and focus handler + const executionLog: string[] = []; + + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { + executionLog.push('lock-acquired'); + const result = await cb(); + executionLog.push('lock-released'); + return result; + }), + }; + + // Simulate poller using the lock + await sharedLock.acquireLockAndRun(() => { + executionLog.push('poller-callback'); + return Promise.resolve('poller-done'); + }); + + // Simulate focus handler using the same lock + await sharedLock.acquireLockAndRun(() => { + executionLog.push('focus-callback'); + return Promise.resolve('focus-done'); + }); + + expect(executionLog).toEqual([ + 'lock-acquired', + 'poller-callback', + 'lock-released', + 'lock-acquired', + 'focus-callback', + 'lock-released', + ]); + }); + + it('mock lock can simulate sequential execution', async () => { + const results: string[] = []; + + // Create a mock that simulates sequential lock behavior + const sharedLock: SafeLockReturn = { + acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { + const result = await cb(); + results.push(result as string); + return result; + }), + }; + + // Both "tabs" try to refresh + const promise1 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab1-result')); + const promise2 = sharedLock.acquireLockAndRun(() => Promise.resolve('tab2-result')); + + await Promise.all([promise1, promise2]); + + expect(results).toContain('tab1-result'); + expect(results).toContain('tab2-result'); + expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index d28eb9a0657..1b587a76288 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -1,13 +1,45 @@ import Lock from 'browser-tabs-lock'; +/** + * Return type for SafeLock providing cross-tab lock coordination. + */ export interface SafeLockReturn { + /** + * Acquires a cross-tab lock and executes the callback while holding it. + * Other tabs attempting to acquire the same lock will wait until this callback completes. + * + * @param cb - Async callback to execute while holding the lock + * @returns The callback's return value, or `false` if lock acquisition times out + */ acquireLockAndRun: (cb: () => Promise) => Promise; } +/** + * Creates a cross-tab lock mechanism for coordinating exclusive operations across browser tabs. + * + * This is used to prevent multiple tabs from performing the same operation simultaneously, + * such as refreshing session tokens. When one tab holds the lock, other tabs will wait + * until the lock is released before proceeding. + * + * @param key - Shared identifier for the lock + * @returns SafeLockReturn with acquireLockAndRun method + * + * @example + * ```typescript + * const tokenLock = SafeLock('clerk.lock.refreshToken'); + * + * // In Tab 1: + * await tokenLock.acquireLockAndRun(async () => { + * await refreshToken(); // Only one tab executes this at a time + * }); + * + * // Tab 2 will wait for Tab 1 to finish before executing its callback + * ``` + */ export function SafeLock(key: string): SafeLockReturn { const lock = new Lock(); - // TODO: Figure out how to fix this linting error + // Release any held locks when the tab is closing to prevent deadlocks // eslint-disable-next-line @typescript-eslint/no-misused-promises window.addEventListener('beforeunload', async () => { await lock.releaseLock(key); @@ -17,13 +49,15 @@ export function SafeLock(key: string): SafeLockReturn { if ('locks' in navigator && isSecureContext) { const controller = new AbortController(); const lockTimeout = setTimeout(() => controller.abort(), 4999); + return await navigator.locks .request(key, { signal: controller.signal }, async () => { clearTimeout(lockTimeout); return await cb(); }) .catch(() => { - // browser-tabs-lock never seems to throw, so we are mirroring the behavior here + // Lock request was aborted (timeout) or failed + // Return false to indicate lock was not acquired (matches browser-tabs-lock behavior) return false; }); } @@ -35,6 +69,8 @@ export function SafeLock(key: string): SafeLockReturn { await lock.releaseLock(key); } } + + return false; }; return { acquireLockAndRun }; From ca20b97d31768e99db462d7b5ac11a39c66ec1e9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 07:14:53 -0600 Subject: [PATCH 03/11] Move locking into getToken() --- .../fix-token-refresh-race-condition.md | 10 +- .../src/core/auth/SessionCookiePoller.ts | 29 +--- .../__tests__/SessionCookiePoller.test.ts | 142 +++++++++--------- packages/clerk-js/src/core/auth/safeLock.ts | 71 ++++----- .../clerk-js/src/core/resources/Session.ts | 97 +++++++++--- 5 files changed, 182 insertions(+), 167 deletions(-) diff --git a/.changeset/fix-token-refresh-race-condition.md b/.changeset/fix-token-refresh-race-condition.md index 527be0d877d..80150adce83 100644 --- a/.changeset/fix-token-refresh-race-condition.md +++ b/.changeset/fix-token-refresh-race-condition.md @@ -2,5 +2,13 @@ "@clerk/clerk-js": patch --- -Fix race condition where multiple browser tabs could fetch session tokens simultaneously. The `refreshTokenOnFocus` handler now uses the same cross-tab lock as the session token poller, preventing duplicate API calls when switching between tabs or when focus events fire while another tab is already refreshing the token. +Fix race condition where multiple browser tabs could fetch session tokens simultaneously. +Key changes: +- `getToken()` now uses a cross-tab lock (via Web Locks API or localStorage fallback) to coordinate token refresh operations +- Per-tokenId locking allows different token types (different orgs, JWT templates) to be fetched in parallel while preventing duplicates for the same token +- Double-checked locking pattern: cache is checked before and after acquiring the lock, so tabs that wait will find the token already cached by the tab that fetched it +- Graceful timeout handling: if lock acquisition times out (~5 seconds), the operation proceeds in degraded mode rather than failing +- Removed redundant lock from SessionCookiePoller since coordination is now handled within `getToken()` itself + +This ensures all callers of `getToken()` (pollers, focus handlers, user code) automatically benefit from cross-tab coordination. diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index a4c874ed56c..32d3f894faf 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -1,40 +1,19 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; -import type { SafeLockReturn } from './safeLock'; -import { SafeLock } from './safeLock'; - -export const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; const INTERVAL_IN_MS = 5 * 1_000; /** - * Polls for session token refresh at regular intervals with cross-tab coordination. - * - * @example - * ```typescript - * // Create a shared lock for coordination with focus handlers - * const sharedLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); - * - * // Poller uses the shared lock - * const poller = new SessionCookiePoller(sharedLock); - * poller.startPollingForSessionToken(() => refreshToken()); + * Polls for session token refresh at regular intervals. * - * // Focus handler can use the same lock to prevent races - * window.addEventListener('focus', () => { - * sharedLock.acquireLockAndRun(() => refreshToken()); - * }); - * ``` + * Note: Cross-tab coordination is handled within Session.getToken() itself, + * so this poller simply triggers the refresh callback without additional locking. */ export class SessionCookiePoller { - private lock: SafeLockReturn; private workerTimers = createWorkerTimers(); private timerId: ReturnType | null = null; // Disallows for multiple `startPollingForSessionToken()` calls before `callback` is executed. private initiated = false; - constructor(lock?: SafeLockReturn) { - this.lock = lock ?? SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); - } - public startPollingForSessionToken(cb: () => Promise): void { if (this.timerId || this.initiated) { return; @@ -42,7 +21,7 @@ export class SessionCookiePoller { const run = async () => { this.initiated = true; - await this.lock.acquireLockAndRun(cb); + await cb(); this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS); }; diff --git a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts index c02258b026c..71b7dbfc022 100644 --- a/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/SessionCookiePoller.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { SafeLockReturn } from '../safeLock'; import { SessionCookiePoller } from '../SessionCookiePoller'; describe('SessionCookiePoller', () => { @@ -13,108 +12,70 @@ describe('SessionCookiePoller', () => { vi.restoreAllMocks(); }); - describe('shared lock coordination', () => { - it('accepts an external lock for coordination with other components', () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + describe('startPollingForSessionToken', () => { + it('executes callback immediately on start', async () => { + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); poller.startPollingForSessionToken(callback); - // Verify the shared lock is used - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + // Flush microtasks to let the async run() execute + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(1); poller.stopPollingForSessionToken(); }); - it('creates internal lock when none provided (backward compatible)', () => { - // Should not throw when no lock is provided + it('prevents multiple concurrent polling sessions', async () => { const poller = new SessionCookiePoller(); - expect(poller).toBeInstanceOf(SessionCookiePoller); - }); - - it('enables focus handler and poller to share the same lock', () => { - // This test demonstrates the shared lock pattern used in AuthCookieService - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockImplementation(async (cb: () => Promise) => { - return cb(); - }), - }; - - const poller = new SessionCookiePoller(sharedLock); - const pollerCallback = vi.fn().mockResolvedValue('poller-result'); + const callback = vi.fn().mockResolvedValue(undefined); - // Poller uses the shared lock - poller.startPollingForSessionToken(pollerCallback); + poller.startPollingForSessionToken(callback); + poller.startPollingForSessionToken(callback); // Second call should be ignored - // Simulate focus handler also using the shared lock (like AuthCookieService does) - const focusCallback = vi.fn().mockResolvedValue('focus-result'); - void sharedLock.acquireLockAndRun(focusCallback); + await Promise.resolve(); - // Both should use the same lock instance - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(pollerCallback); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(focusCallback); + expect(callback).toHaveBeenCalledTimes(1); poller.stopPollingForSessionToken(); }); }); - describe('startPollingForSessionToken', () => { - it('executes callback immediately on start', () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + describe('stopPollingForSessionToken', () => { + it('stops polling when called', async () => { + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); poller.startPollingForSessionToken(callback); + await Promise.resolve(); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledWith(callback); + expect(callback).toHaveBeenCalledTimes(1); poller.stopPollingForSessionToken(); - }); - - it('prevents multiple concurrent polling sessions', () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); - const callback = vi.fn().mockResolvedValue(undefined); - poller.startPollingForSessionToken(callback); - poller.startPollingForSessionToken(callback); // Second call should be ignored - - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + // Advance time - callback should not be called again + await vi.advanceTimersByTimeAsync(10000); - poller.stopPollingForSessionToken(); + expect(callback).toHaveBeenCalledTimes(1); }); - }); - describe('stopPollingForSessionToken', () => { it('allows restart after stop', async () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); // Start and stop poller.startPollingForSessionToken(callback); + await Promise.resolve(); poller.stopPollingForSessionToken(); - // Clear mock to check restart - vi.mocked(sharedLock.acquireLockAndRun).mockClear(); + expect(callback).toHaveBeenCalledTimes(1); // Should be able to start again poller.startPollingForSessionToken(callback); - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(2); poller.stopPollingForSessionToken(); }); @@ -122,23 +83,58 @@ describe('SessionCookiePoller', () => { describe('polling interval', () => { it('schedules next poll after callback completes', async () => { - const sharedLock: SafeLockReturn = { - acquireLockAndRun: vi.fn().mockResolvedValue(undefined), - }; - - const poller = new SessionCookiePoller(sharedLock); + const poller = new SessionCookiePoller(); const callback = vi.fn().mockResolvedValue(undefined); poller.startPollingForSessionToken(callback); // Initial call - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(1); // Wait for first interval (5 seconds) await vi.advanceTimersByTimeAsync(5000); // Should have scheduled another call - expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledTimes(2); + + // Another interval + await vi.advanceTimersByTimeAsync(5000); + expect(callback).toHaveBeenCalledTimes(3); + + poller.stopPollingForSessionToken(); + }); + + it('waits for callback to complete before scheduling next poll', async () => { + const poller = new SessionCookiePoller(); + + let resolveCallback: () => void; + const callbackPromise = new Promise(resolve => { + resolveCallback = resolve; + }); + const callback = vi.fn().mockReturnValue(callbackPromise); + + poller.startPollingForSessionToken(callback); + + // Let the first call start + await Promise.resolve(); + expect(callback).toHaveBeenCalledTimes(1); + + // Advance time while callback is still running - should NOT schedule next poll + // because the callback promise hasn't resolved yet + await vi.advanceTimersByTimeAsync(5000); + + // Should still only be 1 call since previous call hasn't completed + expect(callback).toHaveBeenCalledTimes(1); + + // Complete the callback + resolveCallback!(); + await Promise.resolve(); + + // Now advance time for the next interval + await vi.advanceTimersByTimeAsync(5000); + + expect(callback).toHaveBeenCalledTimes(2); poller.stopPollingForSessionToken(); }); diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index 1b587a76288..33a5d58f4f8 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -1,42 +1,18 @@ import Lock from 'browser-tabs-lock'; -/** - * Return type for SafeLock providing cross-tab lock coordination. - */ -export interface SafeLockReturn { - /** - * Acquires a cross-tab lock and executes the callback while holding it. - * Other tabs attempting to acquire the same lock will wait until this callback completes. - * - * @param cb - Async callback to execute while holding the lock - * @returns The callback's return value, or `false` if lock acquisition times out - */ - acquireLockAndRun: (cb: () => Promise) => Promise; -} +import { debugLogger } from '@/utils/debug'; + +const LOCK_TIMEOUT_MS = 4999; /** - * Creates a cross-tab lock mechanism for coordinating exclusive operations across browser tabs. - * - * This is used to prevent multiple tabs from performing the same operation simultaneously, - * such as refreshing session tokens. When one tab holds the lock, other tabs will wait - * until the lock is released before proceeding. + * Creates a cross-tab lock for coordinating exclusive operations across browser tabs. * - * @param key - Shared identifier for the lock - * @returns SafeLockReturn with acquireLockAndRun method + * Uses Web Locks API in secure contexts (HTTPS), falling back to browser-tabs-lock + * (localStorage-based) in non-secure contexts. * - * @example - * ```typescript - * const tokenLock = SafeLock('clerk.lock.refreshToken'); - * - * // In Tab 1: - * await tokenLock.acquireLockAndRun(async () => { - * await refreshToken(); // Only one tab executes this at a time - * }); - * - * // Tab 2 will wait for Tab 1 to finish before executing its callback - * ``` + * @param key - Unique identifier for the lock (same key = same lock across all tabs) */ -export function SafeLock(key: string): SafeLockReturn { +export function SafeLock(key: string) { const lock = new Lock(); // Release any held locks when the tab is closing to prevent deadlocks @@ -45,24 +21,31 @@ export function SafeLock(key: string): SafeLockReturn { await lock.releaseLock(key); }); - const acquireLockAndRun = async (cb: () => Promise) => { + /** + * Acquires the cross-tab lock and executes the callback while holding it. + * If lock acquisition fails or times out, executes the callback anyway (degraded mode) + * to ensure the operation completes rather than failing. + */ + const acquireLockAndRun = async (cb: () => Promise): Promise => { if ('locks' in navigator && isSecureContext) { const controller = new AbortController(); - const lockTimeout = setTimeout(() => controller.abort(), 4999); + const lockTimeout = setTimeout(() => controller.abort(), LOCK_TIMEOUT_MS); - return await navigator.locks - .request(key, { signal: controller.signal }, async () => { + try { + return await navigator.locks.request(key, { signal: controller.signal }, async () => { clearTimeout(lockTimeout); return await cb(); - }) - .catch(() => { - // Lock request was aborted (timeout) or failed - // Return false to indicate lock was not acquired (matches browser-tabs-lock behavior) - return false; }); + } catch { + // Lock request was aborted (timeout) or failed + // Execute callback anyway in degraded mode to ensure operation completes + debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); + return await cb(); + } } - if (await lock.acquireLock(key, 5000)) { + // Fallback for non-secure contexts using localStorage-based locking + if (await lock.acquireLock(key, LOCK_TIMEOUT_MS + 1)) { try { return await cb(); } finally { @@ -70,7 +53,9 @@ export function SafeLock(key: string): SafeLockReturn { } } - return false; + // Lock acquisition timed out - execute callback anyway in degraded mode + debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); + return await cb(); }; return { acquireLockAndRun }; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 7ac829ca520..df342acd651 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -35,12 +35,34 @@ import { } from '@/utils/passkeys'; import { TokenId } from '@/utils/tokenId'; +import { SafeLock } from '../auth/safeLock'; import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../errors'; import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; import { BaseResource, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; +/** + * Cache of per-tokenId locks for cross-tab coordination. + * Each unique tokenId gets its own lock, allowing different token types + * (e.g., different orgs, JWT templates) to be fetched in parallel. + */ +const tokenLocks = new Map>(); + +/** + * Gets or creates a cross-tab lock for a specific tokenId. + * Using per-tokenId locks allows different token types to be fetched in parallel + * while still preventing duplicate fetches for the same token across tabs. + */ +function getTokenLock(tokenId: string) { + let lock = tokenLocks.get(tokenId); + if (!lock) { + lock = SafeLock(`clerk.lock.getToken.${tokenId}`); + tokenLocks.set(tokenId, lock); + } + return lock; +} + export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -369,6 +391,7 @@ export class Session extends BaseResource implements SessionResource { const tokenId = this.#getCacheId(template, organizationId); + // Fast path: check cache without lock for immediate hits const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates @@ -390,39 +413,63 @@ export class Session extends BaseResource implements SessionResource { return cachedToken.getRawString() || null; } - debugLogger.info( - 'Fetching new token from API', - { - organizationId, - template, - tokenId, - }, - 'session', - ); + // Cache miss: acquire cross-tab lock before fetching to prevent duplicate API calls + // when multiple tabs try to refresh the token simultaneously. + // Using per-tokenId locks allows different token types to be fetched in parallel. + const tokenLock = getTokenLock(tokenId); + return tokenLock.acquireLockAndRun(async () => { + // Double-check cache after acquiring lock - another tab may have populated it + const cachedEntryAfterLock = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + + if (cachedEntryAfterLock) { + debugLogger.debug( + 'Using cached token after lock (populated by another tab)', + { + tokenId, + }, + 'session', + ); + const cachedToken = await cachedEntryAfterLock.tokenResolver; + if (shouldDispatchTokenUpdate) { + eventBus.emit(events.TokenUpdate, { token: cachedToken }); + } + return cachedToken.getRawString() || null; + } - const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; + debugLogger.info( + 'Fetching new token from API', + { + organizationId, + template, + tokenId, + }, + 'session', + ); - // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId }; + const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; - const tokenResolver = Token.create(path, params, skipCache); + // TODO: update template endpoint to accept organizationId + const params: Record = template ? {} : { organizationId }; - // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests - SessionTokenCache.set({ tokenId, tokenResolver }); + const tokenResolver = Token.create(path, params, skipCache); - return tokenResolver.then(token => { - if (shouldDispatchTokenUpdate) { - eventBus.emit(events.TokenUpdate, { token }); + // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests + SessionTokenCache.set({ tokenId, tokenResolver }); - if (token.jwt) { - this.lastActiveToken = token; - // Emits the updated session with the new token to the state listeners - eventBus.emit(events.SessionTokenResolved, null); + return tokenResolver.then(token => { + if (shouldDispatchTokenUpdate) { + eventBus.emit(events.TokenUpdate, { token }); + + if (token.jwt) { + this.lastActiveToken = token; + // Emits the updated session with the new token to the state listeners + eventBus.emit(events.SessionTokenResolved, null); + } } - } - // Return null when raw string is empty to indicate that there it's signed-out - return token.getRawString() || null; + // Return null when raw string is empty to indicate that there it's signed-out + return token.getRawString() || null; + }); }); } From 04b08a66d72cce7db7d7fb50d45c5e783e470939 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 07:20:44 -0600 Subject: [PATCH 04/11] remove old code --- .../src/core/auth/AuthCookieService.ts | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 575b8e1d289..a801e096aaf 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -20,9 +20,7 @@ import { createSessionCookie } from './cookies/session'; import { getCookieSuffix } from './cookieSuffix'; import type { DevBrowser } from './devBrowser'; import { createDevBrowser } from './devBrowser'; -import type { SafeLockReturn } from './safeLock'; -import { SafeLock } from './safeLock'; -import { REFRESH_SESSION_TOKEN_LOCK_KEY, SessionCookiePoller } from './SessionCookiePoller'; +import { SessionCookiePoller } from './SessionCookiePoller'; // TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller // and we need to avoid updating them concurrently. @@ -48,10 +46,6 @@ export class AuthCookieService { private devBrowser: DevBrowser; private poller: SessionCookiePoller | null = null; private sessionCookie: SessionCookieHandler; - /** - * Shared lock for coordinating token refresh operations across tabs - */ - private tokenRefreshLock: SafeLockReturn; public static async create( clerk: Clerk, @@ -72,11 +66,6 @@ export class AuthCookieService { private instanceType: InstanceType, private clerkEventBus: ReturnType, ) { - // Create shared lock for cross-tab token refresh coordination. - // This lock is used by both the poller and the focus handler to prevent - // concurrent token fetches across tabs. - this.tokenRefreshLock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); - // set cookie on token update eventBus.on(events.TokenUpdate, ({ token }) => { this.updateSessionCookie(token && token.getRawString()); @@ -137,7 +126,7 @@ export class AuthCookieService { public startPollingForToken() { if (!this.poller) { - this.poller = new SessionCookiePoller(this.tokenRefreshLock); + this.poller = new SessionCookiePoller(); this.poller.startPollingForSessionToken(() => this.refreshSessionToken()); } } @@ -158,11 +147,7 @@ export class AuthCookieService { // is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie // is updated too late and not guaranteed to be fresh before the refetch occurs. // While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break. - // - // We use the shared lock to coordinate with the poller and other tabs, preventing - // concurrent token fetches when multiple tabs become visible or when focus events - // fire while the poller is already refreshing the token. - void this.tokenRefreshLock.acquireLockAndRun(() => this.refreshSessionToken({ updateCookieImmediately: true })); + void this.refreshSessionToken({ updateCookieImmediately: true }); } }); } From f94b2144ff56d046956c273de29690a074154bd1 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 07:24:56 -0600 Subject: [PATCH 05/11] wip --- packages/clerk-js/src/core/resources/Session.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index df342acd651..ef88a036170 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -398,13 +398,6 @@ export class Session extends BaseResource implements SessionResource { const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cachedEntry) { - debugLogger.debug( - 'Using cached token (no fetch needed)', - { - tokenId, - }, - 'session', - ); const cachedToken = await cachedEntry.tokenResolver; if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); From be3c3a93f8c026abfffb310f32dd13653f3be567 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 11:37:30 -0600 Subject: [PATCH 06/11] update changeset --- .changeset/fix-token-refresh-race-condition.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.changeset/fix-token-refresh-race-condition.md b/.changeset/fix-token-refresh-race-condition.md index 80150adce83..ba56f1c3de4 100644 --- a/.changeset/fix-token-refresh-race-condition.md +++ b/.changeset/fix-token-refresh-race-condition.md @@ -2,13 +2,4 @@ "@clerk/clerk-js": patch --- -Fix race condition where multiple browser tabs could fetch session tokens simultaneously. - -Key changes: -- `getToken()` now uses a cross-tab lock (via Web Locks API or localStorage fallback) to coordinate token refresh operations -- Per-tokenId locking allows different token types (different orgs, JWT templates) to be fetched in parallel while preventing duplicates for the same token -- Double-checked locking pattern: cache is checked before and after acquiring the lock, so tabs that wait will find the token already cached by the tab that fetched it -- Graceful timeout handling: if lock acquisition times out (~5 seconds), the operation proceeds in degraded mode rather than failing -- Removed redundant lock from SessionCookiePoller since coordination is now handled within `getToken()` itself - -This ensures all callers of `getToken()` (pollers, focus handlers, user code) automatically benefit from cross-tab coordination. +Fix race condition where multiple browser tabs could fetch session tokens simultaneously. `getToken()` now uses a cross-tab lock to coordinate token refresh operations From 4a6769d945489de6899a9602f70bf6b5301cfceb Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 25 Nov 2025 19:36:30 -0600 Subject: [PATCH 07/11] Add lru map to prevent unbounded growth --- .../clerk-js/src/core/resources/Session.ts | 6 +- .../src/utils/__tests__/lru-map.test.ts | 73 +++++++++++++++++++ packages/clerk-js/src/utils/lru-map.ts | 34 +++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 packages/clerk-js/src/utils/__tests__/lru-map.test.ts create mode 100644 packages/clerk-js/src/utils/lru-map.ts diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ef88a036170..bcacee1cea9 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -28,6 +28,7 @@ import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/share import { unixEpochToDate } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; +import { LruMap } from '@/utils/lru-map'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -46,13 +47,12 @@ import { SessionVerification } from './SessionVerification'; * Cache of per-tokenId locks for cross-tab coordination. * Each unique tokenId gets its own lock, allowing different token types * (e.g., different orgs, JWT templates) to be fetched in parallel. + * Uses LRU eviction to prevent unbounded growth. */ -const tokenLocks = new Map>(); +const tokenLocks = new LruMap>(50); /** * Gets or creates a cross-tab lock for a specific tokenId. - * Using per-tokenId locks allows different token types to be fetched in parallel - * while still preventing duplicate fetches for the same token across tabs. */ function getTokenLock(tokenId: string) { let lock = tokenLocks.get(tokenId); diff --git a/packages/clerk-js/src/utils/__tests__/lru-map.test.ts b/packages/clerk-js/src/utils/__tests__/lru-map.test.ts new file mode 100644 index 00000000000..a798fc740c2 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/lru-map.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { LruMap } from '../lru-map'; + +describe('LruMap', () => { + it('stores and retrieves values', () => { + const map = new LruMap(3); + map.set('a', 1); + map.set('b', 2); + + expect(map.get('a')).toBe(1); + expect(map.get('b')).toBe(2); + expect(map.get('c')).toBeUndefined(); + }); + + it('evicts oldest entry when exceeding max size', () => { + const map = new LruMap(3); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + map.set('d', 4); + + expect(map.get('a')).toBeUndefined(); + expect(map.get('b')).toBe(2); + expect(map.get('c')).toBe(3); + expect(map.get('d')).toBe(4); + expect(map.size).toBe(3); + }); + + it('moves accessed entry to most recent position', () => { + const map = new LruMap(3); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + map.get('a'); + + map.set('d', 4); + + expect(map.get('a')).toBe(1); + expect(map.get('b')).toBeUndefined(); + expect(map.get('c')).toBe(3); + expect(map.get('d')).toBe(4); + }); + + it('updates existing entry without eviction', () => { + const map = new LruMap(3); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + map.set('a', 100); + + expect(map.get('a')).toBe(100); + expect(map.size).toBe(3); + + map.set('d', 4); + + expect(map.get('a')).toBe(100); + expect(map.get('b')).toBeUndefined(); + expect(map.size).toBe(3); + }); + + it('handles max size of 1', () => { + const map = new LruMap(1); + map.set('a', 1); + map.set('b', 2); + + expect(map.get('a')).toBeUndefined(); + expect(map.get('b')).toBe(2); + expect(map.size).toBe(1); + }); +}); diff --git a/packages/clerk-js/src/utils/lru-map.ts b/packages/clerk-js/src/utils/lru-map.ts new file mode 100644 index 00000000000..c890fbc69a3 --- /dev/null +++ b/packages/clerk-js/src/utils/lru-map.ts @@ -0,0 +1,34 @@ +/** + * A simple Map with LRU (Least Recently Used) eviction. + * When the map exceeds maxSize, the oldest entries are removed. + */ +export class LruMap extends Map { + constructor(private maxSize: number) { + super(); + } + + override get(key: K): V | undefined { + const value = super.get(key); + if (value !== undefined) { + super.delete(key); + super.set(key, value); + } + return value; + } + + override set(key: K, value: V): this { + if (super.has(key)) { + super.delete(key); + } else { + while (this.size >= this.maxSize) { + const oldest = super.keys().next().value; + if (oldest !== undefined) { + super.delete(oldest); + } else { + break; + } + } + } + return super.set(key, value); + } +} From 5552fb570edad266a9436c9d59a59d0649dcefcd Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 1 Dec 2025 09:04:22 -0600 Subject: [PATCH 08/11] fix safelock beforeunload behavior --- .../src/core/auth/__tests__/safeLock.test.ts | 48 +++++++++++++- packages/clerk-js/src/core/auth/safeLock.ts | 37 +++++++++-- .../core/resources/__tests__/Session.test.ts | 64 +++++++++++++++++++ 3 files changed, 141 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts index e78d6ba9b19..72a6548388d 100644 --- a/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts @@ -1,9 +1,22 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SafeLockReturn } from '../safeLock'; -import { SafeLock } from '../safeLock'; describe('SafeLock', () => { + let SafeLock: typeof import('../safeLock').SafeLock; + let addEventListenerSpy: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + const module = await import('../safeLock'); + SafeLock = module.SafeLock; + addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + }); + + afterEach(() => { + addEventListenerSpy.mockRestore(); + }); + describe('interface contract', () => { it('returns SafeLockReturn interface with acquireLockAndRun method', () => { const lock = SafeLock('test-interface'); @@ -103,4 +116,35 @@ describe('SafeLock', () => { expect(sharedLock.acquireLockAndRun).toHaveBeenCalledTimes(2); }); }); + + describe('beforeunload listener consolidation', () => { + it('registers only one beforeunload listener regardless of lock count', () => { + const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter(call => call[0] === 'beforeunload'); + expect(beforeUnloadCalls).toHaveLength(0); + + SafeLock('lock-1'); + SafeLock('lock-2'); + SafeLock('lock-3'); + + const afterCalls = addEventListenerSpy.mock.calls.filter(call => call[0] === 'beforeunload'); + expect(afterCalls).toHaveLength(1); + }); + + it('fresh module import starts with clean state', async () => { + // First module instance + SafeLock('lock-a'); + expect(addEventListenerSpy.mock.calls.filter(call => call[0] === 'beforeunload')).toHaveLength(1); + + // Reset and get fresh module + vi.resetModules(); + addEventListenerSpy.mockRestore(); + addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + + const freshModule = await import('../safeLock'); + + // Fresh module should register its own listener + freshModule.SafeLock('lock-b'); + expect(addEventListenerSpy.mock.calls.filter(call => call[0] === 'beforeunload')).toHaveLength(1); + }); + }); }); diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index 33a5d58f4f8..c7ac96c9d71 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -4,6 +4,33 @@ import { debugLogger } from '@/utils/debug'; const LOCK_TIMEOUT_MS = 4999; +/** + * Module-level tracking of active locks for cleanup on page unload. + * This ensures we only register one beforeunload listener regardless of how many locks are created. + */ +const activeLocks = new Map(); +let cleanupListenerRegistered = false; + +function registerCleanupListener() { + if (cleanupListenerRegistered) { + return; + } + cleanupListenerRegistered = true; + + // Release all held locks when the tab is closing to prevent deadlocks. + // Note: beforeunload handlers should be synchronous; async operations may not complete. + // We fire-and-forget the release - best effort cleanup. + window.addEventListener('beforeunload', () => { + activeLocks.forEach((lock, key) => { + void lock.releaseLock(key); + }); + }); +} + +export interface SafeLockReturn { + acquireLockAndRun: (cb: () => Promise) => Promise; +} + /** * Creates a cross-tab lock for coordinating exclusive operations across browser tabs. * @@ -12,14 +39,12 @@ const LOCK_TIMEOUT_MS = 4999; * * @param key - Unique identifier for the lock (same key = same lock across all tabs) */ -export function SafeLock(key: string) { +export function SafeLock(key: string): SafeLockReturn { const lock = new Lock(); - // Release any held locks when the tab is closing to prevent deadlocks - // eslint-disable-next-line @typescript-eslint/no-misused-promises - window.addEventListener('beforeunload', async () => { - await lock.releaseLock(key); - }); + // Track this lock for cleanup on page unload + activeLocks.set(key, lock); + registerCleanupListener(); /** * Acquires the cross-tab lock and executes the callback while holding it. diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 4de20208046..46e489aae2e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -426,6 +426,70 @@ describe('Session', () => { expect(requestSpy).toHaveBeenCalledTimes(2); }); + + it('uses cache populated by another source during lock wait (simulates cross-tab)', async () => { + // This test simulates the cross-tab scenario where: + // 1. Tab 2 tries to get a token (cache miss) + // 2. While waiting for the lock, Tab 1 broadcasts a fresh token via BroadcastChannel + // 3. Tab 2 acquires the lock and double-checks the cache + // 4. Tab 2 should use the cached token instead of making an API call + + BaseResource.clerk = clerkMock(); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockClear(); + + // First call populates the cache + const token1 = await session.getToken(); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(token1).toEqual(mockJwt); + + requestSpy.mockClear(); + + // Second call should hit the cache (simulating what Tab 2 would see after broadcast) + const token2 = await session.getToken(); + expect(requestSpy).toHaveBeenCalledTimes(0); + expect(token2).toEqual(mockJwt); + }); + + it('makes API call when skipCache is true even if cache is populated', async () => { + BaseResource.clerk = clerkMock(); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + // Cache is hydrated from lastActiveToken + expect(SessionTokenCache.size()).toBe(1); + + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + requestSpy.mockClear(); + + // With skipCache, should bypass cache and make API call + const token = await session.getToken({ skipCache: true }); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(token).toEqual(mockJwt); + }); }); describe('touch()', () => { From 67dd241036192f0629f6a8c2521cfb0f88e85fd5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 1 Dec 2025 09:11:28 -0600 Subject: [PATCH 09/11] clean up comments --- .../src/core/auth/SessionCookiePoller.ts | 4 +--- packages/clerk-js/src/core/auth/safeLock.ts | 17 --------------- .../clerk-js/src/core/resources/Session.ts | 21 ------------------- .../core/resources/__tests__/Session.test.ts | 10 --------- 4 files changed, 1 insertion(+), 51 deletions(-) diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index 32d3f894faf..0cb7d2919a5 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -4,9 +4,7 @@ const INTERVAL_IN_MS = 5 * 1_000; /** * Polls for session token refresh at regular intervals. - * - * Note: Cross-tab coordination is handled within Session.getToken() itself, - * so this poller simply triggers the refresh callback without additional locking. + * Cross-tab coordination is handled within Session.getToken(). */ export class SessionCookiePoller { private workerTimers = createWorkerTimers(); diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index c7ac96c9d71..6a4157f9505 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -4,10 +4,6 @@ import { debugLogger } from '@/utils/debug'; const LOCK_TIMEOUT_MS = 4999; -/** - * Module-level tracking of active locks for cleanup on page unload. - * This ensures we only register one beforeunload listener regardless of how many locks are created. - */ const activeLocks = new Map(); let cleanupListenerRegistered = false; @@ -17,9 +13,6 @@ function registerCleanupListener() { } cleanupListenerRegistered = true; - // Release all held locks when the tab is closing to prevent deadlocks. - // Note: beforeunload handlers should be synchronous; async operations may not complete. - // We fire-and-forget the release - best effort cleanup. window.addEventListener('beforeunload', () => { activeLocks.forEach((lock, key) => { void lock.releaseLock(key); @@ -42,15 +35,9 @@ export interface SafeLockReturn { export function SafeLock(key: string): SafeLockReturn { const lock = new Lock(); - // Track this lock for cleanup on page unload activeLocks.set(key, lock); registerCleanupListener(); - /** - * Acquires the cross-tab lock and executes the callback while holding it. - * If lock acquisition fails or times out, executes the callback anyway (degraded mode) - * to ensure the operation completes rather than failing. - */ const acquireLockAndRun = async (cb: () => Promise): Promise => { if ('locks' in navigator && isSecureContext) { const controller = new AbortController(); @@ -62,14 +49,11 @@ export function SafeLock(key: string): SafeLockReturn { return await cb(); }); } catch { - // Lock request was aborted (timeout) or failed - // Execute callback anyway in degraded mode to ensure operation completes debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); return await cb(); } } - // Fallback for non-secure contexts using localStorage-based locking if (await lock.acquireLock(key, LOCK_TIMEOUT_MS + 1)) { try { return await cb(); @@ -78,7 +62,6 @@ export function SafeLock(key: string): SafeLockReturn { } } - // Lock acquisition timed out - execute callback anyway in degraded mode debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); return await cb(); }; diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 67300f51adc..04e04892d5f 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -43,17 +43,8 @@ import { SessionTokenCache } from '../tokenCache'; import { BaseResource, PublicUserData, Token, User } from './internal'; import { SessionVerification } from './SessionVerification'; -/** - * Cache of per-tokenId locks for cross-tab coordination. - * Each unique tokenId gets its own lock, allowing different token types - * (e.g., different orgs, JWT templates) to be fetched in parallel. - * Uses LRU eviction to prevent unbounded growth. - */ const tokenLocks = new LruMap>(50); -/** - * Gets or creates a cross-tab lock for a specific tokenId. - */ function getTokenLock(tokenId: string) { let lock = tokenLocks.get(tokenId); if (!lock) { @@ -385,10 +376,7 @@ export class Session extends BaseResource implements SessionResource { const tokenId = this.#getCacheId(template, organizationId); - // Fast path: check cache without lock for immediate hits const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); - - // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cachedEntry) { @@ -396,16 +384,11 @@ export class Session extends BaseResource implements SessionResource { if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } - // Return null when raw string is empty to indicate that there it's signed-out return cachedToken.getRawString() || null; } - // Cache miss: acquire cross-tab lock before fetching to prevent duplicate API calls - // when multiple tabs try to refresh the token simultaneously. - // Using per-tokenId locks allows different token types to be fetched in parallel. const tokenLock = getTokenLock(tokenId); return tokenLock.acquireLockAndRun(async () => { - // Double-check cache after acquiring lock - another tab may have populated it const cachedEntryAfterLock = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); if (cachedEntryAfterLock) { @@ -439,8 +422,6 @@ export class Session extends BaseResource implements SessionResource { const params: Record = template ? {} : { organizationId }; const tokenResolver = Token.create(path, params, skipCache); - - // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests SessionTokenCache.set({ tokenId, tokenResolver }); return tokenResolver.then(token => { @@ -449,12 +430,10 @@ export class Session extends BaseResource implements SessionResource { if (token.jwt) { this.lastActiveToken = token; - // Emits the updated session with the new token to the state listeners eventBus.emit(events.SessionTokenResolved, null); } } - // Return null when raw string is empty to indicate that there it's signed-out return token.getRawString() || null; }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 46e489aae2e..61a3519e779 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -428,12 +428,6 @@ describe('Session', () => { }); it('uses cache populated by another source during lock wait (simulates cross-tab)', async () => { - // This test simulates the cross-tab scenario where: - // 1. Tab 2 tries to get a token (cache miss) - // 2. While waiting for the lock, Tab 1 broadcasts a fresh token via BroadcastChannel - // 3. Tab 2 acquires the lock and double-checks the cache - // 4. Tab 2 should use the cached token instead of making an API call - BaseResource.clerk = clerkMock(); const session = new Session({ @@ -450,14 +444,12 @@ describe('Session', () => { const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; requestSpy.mockClear(); - // First call populates the cache const token1 = await session.getToken(); expect(requestSpy).toHaveBeenCalledTimes(1); expect(token1).toEqual(mockJwt); requestSpy.mockClear(); - // Second call should hit the cache (simulating what Tab 2 would see after broadcast) const token2 = await session.getToken(); expect(requestSpy).toHaveBeenCalledTimes(0); expect(token2).toEqual(mockJwt); @@ -478,13 +470,11 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - // Cache is hydrated from lastActiveToken expect(SessionTokenCache.size()).toBe(1); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; requestSpy.mockClear(); - // With skipCache, should bypass cache and make API call const token = await session.getToken({ skipCache: true }); expect(requestSpy).toHaveBeenCalledTimes(1); From 9f338f4f35d4ca615628975a711cc305d94b3a3c Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 1 Dec 2025 09:16:44 -0600 Subject: [PATCH 10/11] wip --- packages/clerk-js/src/core/resources/Session.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 04e04892d5f..4d650cbc094 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -377,6 +377,7 @@ export class Session extends BaseResource implements SessionResource { const tokenId = this.#getCacheId(template, organizationId); const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; if (cachedEntry) { @@ -422,6 +423,7 @@ export class Session extends BaseResource implements SessionResource { const params: Record = template ? {} : { organizationId }; const tokenResolver = Token.create(path, params, skipCache); + // Cache the promise immediately to prevent concurrent calls from triggering duplicate requests SessionTokenCache.set({ tokenId, tokenResolver }); return tokenResolver.then(token => { From 6d15034dfb9adf3f7a4dc0ce9c353ccd3d5d2b27 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 1 Dec 2025 09:30:29 -0600 Subject: [PATCH 11/11] wip --- .../src/core/auth/__tests__/safeLock.test.ts | 62 ++++++++++++++++++- packages/clerk-js/src/core/auth/safeLock.ts | 10 ++- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts index 72a6548388d..6f6b2afa1a2 100644 --- a/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts +++ b/packages/clerk-js/src/core/auth/__tests__/safeLock.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SafeLockReturn } from '../safeLock'; describe('SafeLock', () => { - let SafeLock: typeof import('../safeLock').SafeLock; + let SafeLock: (key: string) => SafeLockReturn; let addEventListenerSpy: ReturnType; beforeEach(async () => { @@ -37,8 +37,8 @@ describe('SafeLock', () => { describe('Web Locks API path', () => { it('uses Web Locks API when available in secure context', async () => { - // Skip if Web Locks not available (like in jsdom without polyfill) - if (!('locks' in navigator) || !navigator.locks) { + // Skip if Web Locks not available or not in secure context + if (!('locks' in navigator) || !navigator.locks || !isSecureContext) { return; } @@ -117,6 +117,62 @@ describe('SafeLock', () => { }); }); + describe('error handling', () => { + it('propagates callback errors without double-invocation', async () => { + const originalLocks = navigator.locks; + const callbackError = new Error('Callback failed'); + const callback = vi.fn().mockRejectedValue(callbackError); + + const mockRequest = vi.fn().mockImplementation(async (_name, _options, cb) => { + return await cb(); + }); + + Object.defineProperty(navigator, 'locks', { + value: { request: mockRequest }, + configurable: true, + }); + + try { + const lock = SafeLock('test-error-propagation'); + await expect(lock.acquireLockAndRun(callback)).rejects.toThrow('Callback failed'); + expect(callback).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(navigator, 'locks', { + value: originalLocks, + configurable: true, + }); + } + }); + + it('invokes callback in degraded mode on AbortError (timeout)', async () => { + const originalLocks = navigator.locks; + const callback = vi.fn().mockResolvedValue('success'); + + const abortError = new Error('Lock request aborted'); + abortError.name = 'AbortError'; + + const mockRequest = vi.fn().mockRejectedValue(abortError); + + Object.defineProperty(navigator, 'locks', { + value: { request: mockRequest }, + configurable: true, + }); + + try { + const lock = SafeLock('test-abort-fallback'); + const result = await lock.acquireLockAndRun(callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(result).toBe('success'); + } finally { + Object.defineProperty(navigator, 'locks', { + value: originalLocks, + configurable: true, + }); + } + }); + }); + describe('beforeunload listener consolidation', () => { it('registers only one beforeunload listener regardless of lock count', () => { const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter(call => call[0] === 'beforeunload'); diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index 6a4157f9505..781fca23df5 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -48,9 +48,13 @@ export function SafeLock(key: string): SafeLockReturn { clearTimeout(lockTimeout); return await cb(); }); - } catch { - debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); - return await cb(); + } catch (error) { + clearTimeout(lockTimeout); + if (error instanceof Error && error.name === 'AbortError') { + debugLogger.warn('Lock acquisition timed out, proceeding without lock (degraded mode)', { key }, 'safeLock'); + return await cb(); + } + throw error; } }