Skip to content
Open
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
84 changes: 41 additions & 43 deletions packages/core/auth-js/src/lib/locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,57 +218,55 @@ export async function processLock<R>(
): Promise<R> {
const previousOperation = PROCESS_LOCKS[name] ?? Promise.resolve()

const currentOperation = Promise.race(
[
previousOperation.catch(() => {
// ignore error of previous operation that we're waiting to finish
return null
}),
acquireTimeout >= 0
? new Promise((_, reject) => {
setTimeout(() => {
console.warn(
`@supabase/gotrue-js: Lock "${name}" acquisition timed out after ${acquireTimeout}ms. ` +
'This may be caused by another operation holding the lock. ' +
'Consider increasing lockAcquireTimeout or checking for stuck operations.'
)
let timeoutId: ReturnType<typeof setTimeout> | null = null
let timeoutError: ProcessLockAcquireTimeoutError | null = null

reject(
new ProcessLockAcquireTimeoutError(
`Acquiring process lock with name "${name}" timed out`
)
)
}, acquireTimeout)
})
: null,
].filter((x) => x)
)
.catch((e: any) => {
if (e && e.isAcquireTimeout) {
throw e
}
// Set up timeout handling
if (acquireTimeout >= 0) {
timeoutId = setTimeout(() => {
console.warn(
`@supabase/gotrue-js: Lock "${name}" acquisition timed out after ${acquireTimeout}ms. ` +
'This may be caused by another operation holding the lock. ' +
'Consider increasing lockAcquireTimeout or checking for stuck operations.'
)
timeoutError = new ProcessLockAcquireTimeoutError(
`Acquiring process lock with name "${name}" timed out`
)
}, acquireTimeout)
}

return null
})
.then(async () => {
// previous operations finished and we didn't get a race on the acquire
// timeout, so the current operation can finally start
return await fn()
// Create the actual operation that waits for previous lock then runs fn
const lockOperation = async (): Promise<R> => {
// Wait for previous operation to complete (ignore its result/error)
await previousOperation.catch(() => {
// Intentionally ignore errors from previous operation
})

PROCESS_LOCKS[name] = currentOperation.catch(async (e: any) => {
if (e && e.isAcquireTimeout) {
// if the current operation timed out, it doesn't mean that the previous
// operation finished, so we need contnue waiting for it to finish
await previousOperation
// Clear timeout now that we've acquired the lock
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
}

return null
// Check if timeout already fired while we were waiting
if (timeoutError) {
throw timeoutError
}

throw e
// Now we have the lock - run the function
return await fn()
}

const currentOperation = lockOperation()

// Store in PROCESS_LOCKS for next operation to wait on
// Important: Handle errors to avoid unhandled rejections - always resolve
// so subsequent operations can proceed
PROCESS_LOCKS[name] = currentOperation.catch(() => {
// On any error (timeout or fn error), wait for previous op to complete
return previousOperation.catch(() => null)
})

// finally wait for the current operation to finish successfully, with an
// error or with an acquire timeout error
// Return the result (or throw timeout/error)
return await currentOperation
}
71 changes: 70 additions & 1 deletion packages/core/auth-js/test/lib/locks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,73 @@ describe('processLock', () => {

await expect(processLock('error-test', -1, async () => 'success')).resolves.toBe('success')
})
})

it('should not deadlock when timeout occurs with queued operations', async () => {
const results: string[] = []

// Operation 1: Holds lock for 500ms
const op1 = processLock('deadlock-test', -1, async () => {
await new Promise((resolve) => setTimeout(resolve, 500))
results.push('op1-complete')
return 'op1'
})

// Small delay to ensure op1 starts first
await new Promise((resolve) => setTimeout(resolve, 10))

// Operation 2: Times out after 100ms
const op2 = processLock('deadlock-test', 100, async () => {
results.push('op2-complete')
return 'op2'
})

// Operation 3: Should NOT deadlock - should run after op1
const op3 = processLock('deadlock-test', 2000, async () => {
results.push('op3-complete')
return 'op3'
})

// Verify behavior
await expect(op1).resolves.toBe('op1')
await expect(op2).rejects.toMatchObject({ isAcquireTimeout: true })
await expect(op3).resolves.toBe('op3') // ✅ Should succeed, not hang

// Verify execution order
expect(results).toEqual(['op1-complete', 'op3-complete'])
}, 10000)

it('should handle rapid successive operations with mixed timeouts', async () => {
const results: number[] = []

// Fire 10 operations rapidly
// Operation 0 will acquire the lock immediately (no wait) so it completes
// Operations 3, 6, 9 have 50ms timeouts but will need to wait for previous ops
// Each op takes 100ms, so ops with 50ms timeout that queue will timeout
const operations = Array.from({ length: 10 }, (_, i) => {
const timeout = i % 3 === 0 ? 50 : -1 // Every 3rd operation has short timeout
return processLock('rapid-test', timeout, async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
results.push(i)
return i
})
})

const settled = await Promise.allSettled(operations)

// Operation 0 acquires lock immediately (no waiting), so it succeeds
expect(settled[0].status).toBe('fulfilled')

// Operations 3, 6, 9 have 50ms timeouts but must wait for prior ops
// Since each op takes 100ms, these will timeout while waiting
expect(settled[3].status).toBe('rejected')
expect(settled[6].status).toBe('rejected')
expect(settled[9].status).toBe('rejected')

// Operations 1, 2 have infinite timeout so they succeed
expect(settled[1].status).toBe('fulfilled')
expect(settled[2].status).toBe('fulfilled')

// Verify no operations deadlocked (all completed)
expect(results.length).toBeGreaterThan(0)
}, 15000)
})