Skip to content
Open
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
74 changes: 55 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,11 @@ mutex.isLocked();
### Cancelling pending locks

Pending locks can be cancelled by calling `cancel()` on the mutex. This will reject
all pending locks with `E_CANCELED`:
all pending locks with `CanceledError`:

Promise style:
```typescript
import {E_CANCELED} from 'async-mutex';
import {CanceledError} from 'async-mutex';

mutex
.runExclusive(() => {
Expand All @@ -192,29 +192,29 @@ mutex
// ...
})
.catch(e => {
if (e === E_CANCELED) {
if (e instanceof CanceledError) {
// ...
}
});
```

async/await:
```typescript
import {E_CANCELED} from 'async-mutex';
import {CanceledError} from 'async-mutex';

try {
await mutex.runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_CANCELED) {
if (e instanceof CanceledError) {
// ...
}
}
```

This works with `acquire`, too:
if `acquire` is used for locking, the resulting promise will reject with `E_CANCELED`.
if `acquire` is used for locking, the resulting promise will reject with `CanceledError`.

The error that is thrown can be customized by passing a different error to the `Mutex`
constructor:
Expand Down Expand Up @@ -380,11 +380,11 @@ semaphore.setValue();
### Cancelling pending locks

Pending locks can be cancelled by calling `cancel()` on the semaphore. This will reject
all pending locks with `E_CANCELED`:
all pending locks with `CanceledError`:

Promise style:
```typescript
import {E_CANCELED} from 'async-mutex';
import {CanceledError} from 'async-mutex';

semaphore
.runExclusive(() => {
Expand All @@ -394,29 +394,29 @@ semaphore
// ...
})
.catch(e => {
if (e === E_CANCELED) {
if (e instanceof CanceledError) {
// ...
}
});
```

async/await:
```typescript
import {E_CANCELED} from 'async-mutex';
import {CanceledError} from 'async-mutex';

try {
await semaphore.runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_CANCELED) {
if (e instanceof CanceledError) {
// ...
}
}
```

This works with `acquire`, too:
if `acquire` is used for locking, the resulting promise will reject with `E_CANCELED`.
if `acquire` is used for locking, the resulting promise will reject with `CanceledError`.

The error that is thrown can be customized by passing a different error to the `Semaphore`
constructor:
Expand Down Expand Up @@ -463,7 +463,7 @@ to both semaphores and mutexes and changes the behavior of `acquire` and
`runExclusive` accordingly.

```typescript
import {withTimeout, E_TIMEOUT} from 'async-mutex';
import {withTimeout, TimeoutError} from 'async-mutex';

const mutexWithTimeout = withTimeout(new Mutex(), 100);
const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100);
Expand All @@ -473,7 +473,7 @@ The API of the decorated mutex or semaphore is unchanged.

The second argument of `withTimeout` is the timeout in milliseconds. After the
timeout is exceeded, the promise returned by `acquire` and `runExclusive` will
reject with `E_TIMEOUT`. The latter will not run the provided callback in case
reject with `TimeoutError`. The latter will not run the provided callback in case
of an timeout.

The third argument of `withTimeout` is optional and can be used to
Expand All @@ -489,11 +489,11 @@ const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100, new Error('new f
A shortcut exists for the case where you do not want to wait for a lock to
be available at all. The `tryAcquire` decorator can be applied to both mutexes
and semaphores and changes the behavior of `acquire` and `runExclusive` to
immediately throw `E_ALREADY_LOCKED` if the mutex is not available.
immediately throw `AlreadyLockedError` if the mutex is not available.

Promise style:
```typescript
import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex';
import {tryAcquire, AlreadyLockedError} from 'async-mutex';

tryAcquire(semaphoreOrMutex)
.runExclusive(() => {
Expand All @@ -503,22 +503,22 @@ tryAcquire(semaphoreOrMutex)
// ...
})
.catch(e => {
if (e === E_ALREADY_LOCKED) {
if (e instanceof AlreadyLockedError) {
// ...
}
});
```

async/await:
```typescript
import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex';
import {tryAcquire, AlreadyLockedError} from 'async-mutex';

try {
await tryAcquire(semaphoreOrMutex).runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_ALREADY_LOCKED) {
if (e instanceof AlreadyLockedError) {
// ...
}
}
Expand All @@ -533,6 +533,42 @@ tryAcquire(semaphoreOrMutex, new Error('new fancy error'))
// ...
});
```

## Migration from v0.x to v1.0

**v1.0 introduces breaking changes** to improve error handling with proper stack traces.

### Error Checking

**Before (v0.x):**
```javascript
import { E_TIMEOUT, E_CANCELED, E_ALREADY_LOCKED } from 'async-mutex';

try {
await mutex.runExclusive(() => { /* ... */ });
} catch (e) {
if (e === E_TIMEOUT) { } // ❌ No longer works
if (e === E_CANCELED) { } // ❌ No longer works
if (e === E_ALREADY_LOCKED) { } // ❌ No longer works
}
```

**After (v1.0+):**
```javascript
import { TimeoutError, CanceledError, AlreadyLockedError } from 'async-mutex';

try {
await mutex.runExclusive(() => { /* ... */ });
} catch (e) {
if (e instanceof TimeoutError) { } // ✓ Use instanceof
if (e instanceof CanceledError) { } // ✓ Use instanceof
if (e instanceof AlreadyLockedError) { } // ✓ Use instanceof
}
```

The old singleton errors had unusable stack traces (captured at module load time). Error classes
provide accurate, helpful stack traces for debugging.

# License

Feel free to use this library under the conditions of the MIT license.
4 changes: 2 additions & 2 deletions src/Semaphore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { E_CANCELED } from './errors';
import { CanceledError } from './errors';
import SemaphoreInterface from './SemaphoreInterface';


Expand All @@ -19,7 +19,7 @@ interface Waiter {
}

class Semaphore implements SemaphoreInterface {
constructor(private _value: number, private _cancelError: Error = E_CANCELED) {}
constructor(private _value: number, private _cancelError: Error = new CanceledError()) {}

acquire(weight = 1, priority = 0): Promise<[number, SemaphoreInterface.Releaser]> {
if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`);
Expand Down
29 changes: 26 additions & 3 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
export const E_TIMEOUT = new Error('timeout while waiting for mutex to become available');
export const E_ALREADY_LOCKED = new Error('mutex already locked');
export const E_CANCELED = new Error('request for lock canceled');
export class TimeoutError extends Error {
constructor() {
super('timeout while waiting for mutex to become available');
this.name = 'TimeoutError';
// Fix prototype chain for ES5
Object.setPrototypeOf(this, TimeoutError.prototype);
}
}

export class AlreadyLockedError extends Error {
constructor() {
super('mutex already locked');
this.name = 'AlreadyLockedError';
// Fix prototype chain for ES5
Object.setPrototypeOf(this, AlreadyLockedError.prototype);
}
}

export class CanceledError extends Error {
constructor() {
super('request for lock canceled');
this.name = 'CanceledError';
// Fix prototype chain for ES5
Object.setPrototypeOf(this, CanceledError.prototype);
}
}
6 changes: 3 additions & 3 deletions src/tryAcquire.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { E_ALREADY_LOCKED } from './errors';
import { AlreadyLockedError } from './errors';
import MutexInterface from './MutexInterface';
import SemaphoreInterface from './SemaphoreInterface';
import { withTimeout } from './withTimeout';
Expand All @@ -8,8 +8,8 @@ export function tryAcquire(semaphore: SemaphoreInterface, alreadyAcquiredError?:
// eslint-disable-next-lisne @typescript-eslint/explicit-module-boundary-types
export function tryAcquire(
sync: MutexInterface | SemaphoreInterface,
alreadyAcquiredError = E_ALREADY_LOCKED
alreadyAcquiredError?: Error
): typeof sync {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return withTimeout(sync as any, 0, alreadyAcquiredError);
return withTimeout(sync as any, 0, alreadyAcquiredError ?? new AlreadyLockedError());
}
8 changes: 4 additions & 4 deletions src/withTimeout.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { E_TIMEOUT } from './errors';
import { TimeoutError } from './errors';
import MutexInterface from './MutexInterface';
import SemaphoreInterface from './SemaphoreInterface';

export function withTimeout(mutex: MutexInterface, timeout: number, timeoutError?: Error): MutexInterface;
export function withTimeout(semaphore: SemaphoreInterface, timeout: number, timeoutError?: Error): SemaphoreInterface;
export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: number, timeoutError = E_TIMEOUT): any {
export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: number, timeoutError?: Error): any {
return {
acquire: (weightOrPriority?: number, priority?: number): Promise<MutexInterface.Releaser | [number, SemaphoreInterface.Releaser]> => {
let weight: number | undefined;
Expand All @@ -24,7 +24,7 @@ export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout:

const handle = setTimeout(() => {
isTimeout = true;
reject(timeoutError);
reject(timeoutError ?? new TimeoutError());
}, timeout);

try {
Expand Down Expand Up @@ -91,7 +91,7 @@ export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout:
}

return new Promise((resolve, reject) => {
const handle = setTimeout(() => reject(timeoutError), timeout);
const handle = setTimeout(() => reject(timeoutError ?? new TimeoutError()), timeout);
(isSemaphore(sync)
? sync.waitForUnlock(weight, priority)
: sync.waitForUnlock(priority)
Expand Down
8 changes: 4 additions & 4 deletions test/mutex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as assert from 'assert';

import { InstalledClock, install } from '@sinonjs/fake-timers';

import { E_CANCELED } from '../src/errors';
import { CanceledError } from '../src/errors';
import Mutex from '../src/Mutex';
import MutexInterface from '../src/MutexInterface';
import { withTimer } from './util';
Expand Down Expand Up @@ -191,16 +191,16 @@ export const mutexSuite = (factory: (cancelError?: Error) => MutexInterface): vo
assert.strictEqual(v, 3);
});

test('cancel rejects all pending locks witth E_CANCELED', async () => {
test('cancel rejects all pending locks with CanceledError', async () => {
await mutex.acquire();

const ticket = mutex.acquire();
const result = mutex.runExclusive(() => undefined);

mutex.cancel();

await assert.rejects(ticket, E_CANCELED);
await assert.rejects(result, E_CANCELED);
await assert.rejects(ticket, (err: unknown): boolean => err instanceof CanceledError);
await assert.rejects(result, (err: unknown): boolean => err instanceof CanceledError);
});

test('cancel rejects with a custom error if provided', async () => {
Expand Down
8 changes: 4 additions & 4 deletions test/semaphoreSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as assert from 'assert';

import { InstalledClock, install } from '@sinonjs/fake-timers';

import { E_CANCELED } from '../src/errors';
import { CanceledError } from '../src/errors';
import SemaphoreInterface from '../src/SemaphoreInterface';
import { withTimer } from './util';

Expand Down Expand Up @@ -408,7 +408,7 @@ export const semaphoreSuite = (factory: (maxConcurrency: number, err?: Error) =>
semaphore.release();
});

test('cancel rejects all pending locks with E_CANCELED', async () => {
test('cancel rejects all pending locks with CanceledError', async () => {
await semaphore.acquire();
await semaphore.acquire();

Expand All @@ -417,8 +417,8 @@ export const semaphoreSuite = (factory: (maxConcurrency: number, err?: Error) =>

semaphore.cancel();

await assert.rejects(ticket, E_CANCELED);
await assert.rejects(result, E_CANCELED);
await assert.rejects(ticket, (err: unknown) => err instanceof CanceledError);
await assert.rejects(result, (err: unknown) => err instanceof CanceledError);
});

test('cancel rejects with a custom error if provided', async () => {
Expand Down
10 changes: 5 additions & 5 deletions test/tryAcquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as assert from 'assert';

import { InstalledClock, install } from '@sinonjs/fake-timers';

import { E_ALREADY_LOCKED } from '../src/errors';
import { AlreadyLockedError } from '../src/errors';
import Mutex from '../src/Mutex';
import Semaphore from '../src/Semaphore';
import { tryAcquire } from '../src/tryAcquire';
Expand Down Expand Up @@ -31,7 +31,7 @@ suite('tryAcquire', () => {
await assert.rejects(ticket, error);
});

test('acquire rejects with E_ALREADY_LOCKER if no error is provided', async () => {
test('acquire rejects with AlreadyLockedError if no error is provided', async () => {
const mutex = tryAcquire(new Mutex());
await mutex.acquire();

Expand All @@ -40,7 +40,7 @@ suite('tryAcquire', () => {

await clock.tickAsync(0);

await assert.rejects(ticket, E_ALREADY_LOCKED);
await assert.rejects(ticket, (err: unknown) => err instanceof AlreadyLockedError);
});

test('acquire locks the mutex if it is not already locked', async () => {
Expand Down Expand Up @@ -76,7 +76,7 @@ suite('tryAcquire', () => {
await assert.rejects(ticket, error);
});

test('acquire rejects with E_ALREADY_LOCKER if no error is provided', async () => {
test('acquire rejects with AlreadyLockedError if no error is provided', async () => {
const semaphore = tryAcquire(new Semaphore(2));

await semaphore.acquire();
Expand All @@ -87,7 +87,7 @@ suite('tryAcquire', () => {

await clock.tickAsync(0);

await assert.rejects(ticket, E_ALREADY_LOCKED);
await assert.rejects(ticket, (err: unknown) => err instanceof AlreadyLockedError);
});

test('acquire locks the semaphore if it is not already locked', async () => {
Expand Down
Loading