Skip to content

Commit

Permalink
feat: add process lock for optional use in non-browser environments (…
Browse files Browse the repository at this point in the history
…React Native) (#977)

When using the library in non-browser environments like React Native or
other JavaScript-based runtimes (Electron's main process for example)
certain race conditions could still occur.

We've received some signal from customers using React Native that at
scale these become more visible. This is why I'm introducing a
`processLock` that developers can import like so:

```typescript
import { processLock } from '@supabase/auth-js/lib/locks'
```

And add to their apps by specifying the lock option with the process
lock.
  • Loading branch information
hf authored Nov 1, 2024
1 parent 4f21f93 commit 8af88b6
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 0 deletions.
73 changes: 73 additions & 0 deletions src/lib/locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export abstract class LockAcquireTimeoutError extends Error {
}

export class NavigatorLockAcquireTimeoutError extends LockAcquireTimeoutError {}
export class ProcessLockAcquireTimeoutError extends LockAcquireTimeoutError {}

/**
* Implements a global exclusive lock using the Navigator LockManager API. It
Expand Down Expand Up @@ -141,3 +142,75 @@ export async function navigatorLock<R>(
}
)
}

const PROCESS_LOCKS: { [name: string]: Promise<any> } = {}

/**
* Implements a global exclusive lock that works only in the current process.
* Useful for environments like React Native or other non-browser
* single-process (i.e. no concept of "tabs") environments.
*
* Use {@link #navigatorLock} in browser environments.
*
* @param name Name of the lock to be acquired.
* @param acquireTimeout If negative, no timeout. If 0 an error is thrown if
* the lock can't be acquired without waiting. If positive, the lock acquire
* will time out after so many milliseconds. An error is
* a timeout if it has `isAcquireTimeout` set to true.
* @param fn The operation to run once the lock is acquired.
*/
export async function processLock<R>(
name: string,
acquireTimeout: number,
fn: () => Promise<R>
): Promise<R> {
const previousOperation = PROCESS_LOCKS[name] ?? Promise.resolve()

const currentOperation = Promise.race(
[
previousOperation.catch((e: any) => {

Check warning on line 171 in src/lib/locks.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-latest / Node 18

'e' is defined but never used

Check warning on line 171 in src/lib/locks.ts

View workflow job for this annotation

GitHub Actions / release_please

'e' is defined but never used

Check warning on line 171 in src/lib/locks.ts

View workflow job for this annotation

GitHub Actions / Test / OS ubuntu-latest / Node 20

'e' is defined but never used
// ignore error of previous operation that we're waiting to finish
return null
}),
acquireTimeout >= 0
? new Promise((_, reject) => {
setTimeout(() => {
reject(
new ProcessLockAcquireTimeoutError(
`Acquring process lock with name "${name}" timed out`
)
)
}, acquireTimeout)
})
: null,
].filter((x) => x)
)
.catch((e: any) => {
if (e && e.isAcquireTimeout) {
throw e
}

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()
})

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

return null
}

throw e
})

// finally wait for the current operation to finish successfully, with an
// error or with an acquire timeout error
return await currentOperation
}
63 changes: 63 additions & 0 deletions test/lib/locks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { processLock } from '../../src/lib/locks'

describe('processLock', () => {
it('should serialize access correctly', async () => {
const timestamps: number[] = []
const operations: Promise<any>[] = []

let expectedDuration = 0

for (let i = 0; i <= 1000; i += 1) {
const acquireTimeout = Math.random() < 0.3 ? Math.ceil(10 + Math.random() * 100) : -1

operations.push(
(async () => {
try {
await processLock('name', acquireTimeout, async () => {
const start = Date.now()

timestamps.push(start)

let diff = Date.now() - start

while (diff < 10) {
// setTimeout is not very precise, sometimes it actually times out a bit earlier
// so this cycle ensures that it has actually taken >= 10ms
await new Promise((accept) => {
setTimeout(() => accept(null), Math.max(1, 10 - diff))
})

diff = Date.now() - start
}

expectedDuration += Date.now() - start
})
} catch (e: any) {
if (acquireTimeout > -1 && e && e.isAcquireTimeout) {
return null
}

throw e
}
})()
)
}

const start = Date.now()

await Promise.all(operations)

const end = Date.now()

expect(end - start).toBeGreaterThanOrEqual(expectedDuration)
expect(Math.ceil((end - start) / timestamps.length)).toBeGreaterThanOrEqual(10)

for (let i = 1; i < timestamps.length; i += 1) {
expect(timestamps[i]).toBeGreaterThan(timestamps[i - 1])
}

for (let i = 1; i < timestamps.length; i += 1) {
expect(timestamps[i] - timestamps[i - 1]).toBeGreaterThanOrEqual(10)
}
}, 15_000)
})

0 comments on commit 8af88b6

Please sign in to comment.