diff --git a/src/lib/locks.ts b/src/lib/locks.ts index e29ec27bb..7d4bd062a 100644 --- a/src/lib/locks.ts +++ b/src/lib/locks.ts @@ -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 @@ -141,3 +142,75 @@ export async function navigatorLock( } ) } + +const PROCESS_LOCKS: { [name: string]: Promise } = {} + +/** + * 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( + name: string, + acquireTimeout: number, + fn: () => Promise +): Promise { + const previousOperation = PROCESS_LOCKS[name] ?? Promise.resolve() + + const currentOperation = Promise.race( + [ + previousOperation.catch((e: any) => { + // 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 +} diff --git a/test/lib/locks.test.ts b/test/lib/locks.test.ts new file mode 100644 index 000000000..82ab58919 --- /dev/null +++ b/test/lib/locks.test.ts @@ -0,0 +1,63 @@ +import { processLock } from '../../src/lib/locks' + +describe('processLock', () => { + it('should serialize access correctly', async () => { + const timestamps: number[] = [] + const operations: Promise[] = [] + + 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) +})