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)
  • Loading branch information
hf committed Oct 30, 2024
1 parent cb052a9 commit e12fe4d
Show file tree
Hide file tree
Showing 2 changed files with 135 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 / 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
}
62 changes: 62 additions & 0 deletions test/lib/locks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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).toBeGreaterThan(expectedDuration)

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 e12fe4d

Please sign in to comment.