Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add process lock for optional use in non-browser environments (React Native) #977

Merged
merged 1 commit into from
Nov 1, 2024
Merged
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
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 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 @@
}
)
}

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