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
146 changes: 92 additions & 54 deletions packages/core/auth-js/src/lib/locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ export async function navigatorLock<R>(

const abortController = new globalThis.AbortController()

let acquireTimeoutTimer: ReturnType<typeof setTimeout> | undefined

if (acquireTimeout > 0) {
setTimeout(() => {
acquireTimeoutTimer = setTimeout(() => {
abortController.abort()
if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock acquire timed out', name)
Expand Down Expand Up @@ -138,6 +140,13 @@ export async function navigatorLock<R>(
},
async (lock) => {
if (lock) {
// Lock acquired — cancel the acquire-timeout timer so it cannot fire
// while fn() is running. Without this, a delayed timeout abort would
// set signal.aborted = true even though we already hold the lock,
// causing a subsequent steal to be misclassified as "our timeout
// fired" and triggering a spurious steal-back cascade.
clearTimeout(acquireTimeoutTimer)

if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock: acquired', name, lock.name)
}
Expand Down Expand Up @@ -183,77 +192,106 @@ export async function navigatorLock<R>(
'@supabase/gotrue-js: Navigator LockManager returned a null lock when using #request without ifAvailable set to true, it appears this browser is not following the LockManager spec https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request'
)

clearTimeout(acquireTimeoutTimer)
return await fn()
}
}
}
)
} catch (e: any) {
if (e?.name === 'AbortError' && acquireTimeout > 0) {
// The lock acquisition was aborted because the timeout fired while the
// request was still pending. This typically means another lock holder is
// not releasing the lock, possibly due to React Strict Mode's
// double-mount/unmount behavior or a component unmounting mid-operation,
// leaving an orphaned lock.
//
// Recovery: use { steal: true } to forcefully acquire the lock. Per the
// Web Locks API spec, this releases any currently held lock with the same
// name and grants the request immediately, preempting any queued requests.
// The previous holder's callback continues running to completion but no
// longer holds the lock for exclusion purposes.
//
// See: https://github.com/supabase/supabase/issues/42505
if (internals.debug) {
console.log(
'@supabase/gotrue-js: navigatorLock: acquire timeout, recovering by stealing lock',
name
)
}
// Always clear the acquire timeout once the request settles, so it cannot
// fire later and incorrectly abort/log after a rejection.
if (acquireTimeout > 0) {
clearTimeout(acquireTimeoutTimer)
}

console.warn(
`@supabase/gotrue-js: Lock "${name}" was not released within ${acquireTimeout}ms. ` +
'This may indicate an orphaned lock from a component unmount (e.g., React Strict Mode). ' +
'Forcefully acquiring the lock to recover.'
)
if (e?.name === 'AbortError' && acquireTimeout > 0) {
if (abortController.signal.aborted) {
// OUR timeout fired — the lock is genuinely orphaned. Steal it.
//
Comment thread
mandarini marked this conversation as resolved.
Comment thread
mandarini marked this conversation as resolved.
// The lock acquisition was aborted because the timeout fired while the
// request was still pending. This typically means another lock holder is
// not releasing the lock, possibly due to React Strict Mode's
// double-mount/unmount behavior or a component unmounting mid-operation,
// leaving an orphaned lock.
//
// Recovery: use { steal: true } to forcefully acquire the lock. Per the
// Web Locks API spec, this releases any currently held lock with the same
// name and grants the request immediately, preempting any queued requests.
// The previous holder's callback continues running to completion but no
// longer holds the lock for exclusion purposes.
//
// See: https://github.com/supabase/supabase/issues/42505
if (internals.debug) {
console.log(
'@supabase/gotrue-js: navigatorLock: acquire timeout, recovering by stealing lock',
name
)
}

return await Promise.resolve().then(() =>
globalThis.navigator.locks.request(
name,
{
mode: 'exclusive',
steal: true,
},
async (lock) => {
if (lock) {
if (internals.debug) {
console.log(
'@supabase/gotrue-js: navigatorLock: recovered (stolen)',
name,
lock.name
)
}
console.warn(
`@supabase/gotrue-js: Lock "${name}" was not released within ${acquireTimeout}ms. ` +
'This may indicate an orphaned lock from a component unmount (e.g., React Strict Mode). ' +
'Forcefully acquiring the lock to recover.'
)

try {
return await fn()
} finally {
return await Promise.resolve().then(() =>
globalThis.navigator.locks.request(
name,
{
mode: 'exclusive',
steal: true,
},
async (lock) => {
if (lock) {
if (internals.debug) {
console.log(
'@supabase/gotrue-js: navigatorLock: released (stolen)',
'@supabase/gotrue-js: navigatorLock: recovered (stolen)',
name,
lock.name
)
}

try {
return await fn()
} finally {
if (internals.debug) {
console.log(
'@supabase/gotrue-js: navigatorLock: released (stolen)',
name,
lock.name
)
}
}
} else {
// This should not happen with steal: true, but handle gracefully.
console.warn(
'@supabase/gotrue-js: Navigator LockManager returned null lock even with steal: true'
)
return await fn()
}
} else {
// This should not happen with steal: true, but handle gracefully.
console.warn(
'@supabase/gotrue-js: Navigator LockManager returned null lock even with steal: true'
)
return await fn()
}
}
)
)
} else {
// We HELD the lock but another request stole it from us.
// Per the Web Locks spec, our fn() callback is still running as an
// orphaned background task — do NOT steal back. Stealing back would
// cause a cascade (A steals B, B steals A, ...) and run fn() a second
// time concurrently, corrupting auth state.
// Convert to a typed error so callers (e.g. _autoRefreshTokenTick)
// can handle/filter it without it leaking to Sentry as a raw AbortError.
if (internals.debug) {
console.log(
'@supabase/gotrue-js: navigatorLock: lock was stolen by another request',
name
)
}

throw new NavigatorLockAcquireTimeoutError(
`Lock "${name}" was released because another request stole it`
)
)
}
}

throw e
Expand Down
26 changes: 26 additions & 0 deletions packages/core/auth-js/test/lib/locks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ describe('navigatorLock', () => {
expect(callCount).toBe(2)
})

it('should throw NavigatorLockAcquireTimeoutError when the held lock is stolen by another request', async () => {
// Simulate: we acquire the lock (callback is invoked, fn() starts running),
// but then another request steals it — the outer promise rejects with AbortError
// even though our AbortController signal has NOT been aborted (signal.aborted remains false).
const mockFn = jest.fn(async () => 'result')
;(globalThis.navigator.locks.request as jest.Mock).mockImplementation(
(_name: string, _options: any, callback: (lock: any) => Promise<any>) => {
// Invoke callback so fn() runs (we held the lock)
callback({ name: 'test' })
// Outer promise rejects independently — another request stole the lock
return Promise.reject(
new DOMException("Lock broken by another request with the 'steal' option.", 'AbortError')
)
}
)

await expect(navigatorLock('test', 100, mockFn)).rejects.toMatchObject({
isAcquireTimeout: true,
})

// fn() ran exactly once (while we held the lock) — no steal-back re-execution
expect(mockFn).toHaveBeenCalledTimes(1)
// Must NOT have tried to steal back (no second call to navigator.locks.request)
expect(globalThis.navigator.locks.request).toHaveBeenCalledTimes(1)
})

it('should propagate non-AbortError errors without attempting steal', async () => {
; (globalThis.navigator.locks.request as jest.Mock).mockImplementation(() => {
return Promise.reject(new Error('some other error'))
Expand Down
Loading