Skip to content

Commit 8af88b6

Browse files
authored
feat: add process lock for optional use in non-browser environments (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.
1 parent 4f21f93 commit 8af88b6

File tree

2 files changed

+136
-0
lines changed

2 files changed

+136
-0
lines changed

src/lib/locks.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export abstract class LockAcquireTimeoutError extends Error {
2929
}
3030

3131
export class NavigatorLockAcquireTimeoutError extends LockAcquireTimeoutError {}
32+
export class ProcessLockAcquireTimeoutError extends LockAcquireTimeoutError {}
3233

3334
/**
3435
* Implements a global exclusive lock using the Navigator LockManager API. It
@@ -141,3 +142,75 @@ export async function navigatorLock<R>(
141142
}
142143
)
143144
}
145+
146+
const PROCESS_LOCKS: { [name: string]: Promise<any> } = {}
147+
148+
/**
149+
* Implements a global exclusive lock that works only in the current process.
150+
* Useful for environments like React Native or other non-browser
151+
* single-process (i.e. no concept of "tabs") environments.
152+
*
153+
* Use {@link #navigatorLock} in browser environments.
154+
*
155+
* @param name Name of the lock to be acquired.
156+
* @param acquireTimeout If negative, no timeout. If 0 an error is thrown if
157+
* the lock can't be acquired without waiting. If positive, the lock acquire
158+
* will time out after so many milliseconds. An error is
159+
* a timeout if it has `isAcquireTimeout` set to true.
160+
* @param fn The operation to run once the lock is acquired.
161+
*/
162+
export async function processLock<R>(
163+
name: string,
164+
acquireTimeout: number,
165+
fn: () => Promise<R>
166+
): Promise<R> {
167+
const previousOperation = PROCESS_LOCKS[name] ?? Promise.resolve()
168+
169+
const currentOperation = Promise.race(
170+
[
171+
previousOperation.catch((e: any) => {
172+
// ignore error of previous operation that we're waiting to finish
173+
return null
174+
}),
175+
acquireTimeout >= 0
176+
? new Promise((_, reject) => {
177+
setTimeout(() => {
178+
reject(
179+
new ProcessLockAcquireTimeoutError(
180+
`Acquring process lock with name "${name}" timed out`
181+
)
182+
)
183+
}, acquireTimeout)
184+
})
185+
: null,
186+
].filter((x) => x)
187+
)
188+
.catch((e: any) => {
189+
if (e && e.isAcquireTimeout) {
190+
throw e
191+
}
192+
193+
return null
194+
})
195+
.then(async () => {
196+
// previous operations finished and we didn't get a race on the acquire
197+
// timeout, so the current operation can finally start
198+
return await fn()
199+
})
200+
201+
PROCESS_LOCKS[name] = currentOperation.catch(async (e: any) => {
202+
if (e && e.isAcquireTimeout) {
203+
// if the current operation timed out, it doesn't mean that the previous
204+
// operation finished, so we need contnue waiting for it to finish
205+
await previousOperation
206+
207+
return null
208+
}
209+
210+
throw e
211+
})
212+
213+
// finally wait for the current operation to finish successfully, with an
214+
// error or with an acquire timeout error
215+
return await currentOperation
216+
}

test/lib/locks.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { processLock } from '../../src/lib/locks'
2+
3+
describe('processLock', () => {
4+
it('should serialize access correctly', async () => {
5+
const timestamps: number[] = []
6+
const operations: Promise<any>[] = []
7+
8+
let expectedDuration = 0
9+
10+
for (let i = 0; i <= 1000; i += 1) {
11+
const acquireTimeout = Math.random() < 0.3 ? Math.ceil(10 + Math.random() * 100) : -1
12+
13+
operations.push(
14+
(async () => {
15+
try {
16+
await processLock('name', acquireTimeout, async () => {
17+
const start = Date.now()
18+
19+
timestamps.push(start)
20+
21+
let diff = Date.now() - start
22+
23+
while (diff < 10) {
24+
// setTimeout is not very precise, sometimes it actually times out a bit earlier
25+
// so this cycle ensures that it has actually taken >= 10ms
26+
await new Promise((accept) => {
27+
setTimeout(() => accept(null), Math.max(1, 10 - diff))
28+
})
29+
30+
diff = Date.now() - start
31+
}
32+
33+
expectedDuration += Date.now() - start
34+
})
35+
} catch (e: any) {
36+
if (acquireTimeout > -1 && e && e.isAcquireTimeout) {
37+
return null
38+
}
39+
40+
throw e
41+
}
42+
})()
43+
)
44+
}
45+
46+
const start = Date.now()
47+
48+
await Promise.all(operations)
49+
50+
const end = Date.now()
51+
52+
expect(end - start).toBeGreaterThanOrEqual(expectedDuration)
53+
expect(Math.ceil((end - start) / timestamps.length)).toBeGreaterThanOrEqual(10)
54+
55+
for (let i = 1; i < timestamps.length; i += 1) {
56+
expect(timestamps[i]).toBeGreaterThan(timestamps[i - 1])
57+
}
58+
59+
for (let i = 1; i < timestamps.length; i += 1) {
60+
expect(timestamps[i] - timestamps[i - 1]).toBeGreaterThanOrEqual(10)
61+
}
62+
}, 15_000)
63+
})

0 commit comments

Comments
 (0)