diff --git a/README.md b/README.md index b4ca708..93d0d7a 100644 --- a/README.md +++ b/README.md @@ -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(() => { @@ -192,7 +192,7 @@ mutex // ... }) .catch(e => { - if (e === E_CANCELED) { + if (e instanceof CanceledError) { // ... } }); @@ -200,21 +200,21 @@ mutex 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: @@ -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(() => { @@ -394,7 +394,7 @@ semaphore // ... }) .catch(e => { - if (e === E_CANCELED) { + if (e instanceof CanceledError) { // ... } }); @@ -402,21 +402,21 @@ semaphore 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: @@ -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); @@ -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 @@ -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(() => { @@ -503,7 +503,7 @@ tryAcquire(semaphoreOrMutex) // ... }) .catch(e => { - if (e === E_ALREADY_LOCKED) { + if (e instanceof AlreadyLockedError) { // ... } }); @@ -511,14 +511,14 @@ tryAcquire(semaphoreOrMutex) 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) { // ... } } @@ -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. diff --git a/src/Semaphore.ts b/src/Semaphore.ts index b912f2d..a036fef 100644 --- a/src/Semaphore.ts +++ b/src/Semaphore.ts @@ -1,4 +1,4 @@ -import { E_CANCELED } from './errors'; +import { CanceledError } from './errors'; import SemaphoreInterface from './SemaphoreInterface'; @@ -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`); diff --git a/src/errors.ts b/src/errors.ts index 05d84ea..1b59dbd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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); + } +} diff --git a/src/tryAcquire.ts b/src/tryAcquire.ts index b4ccb47..173acd3 100644 --- a/src/tryAcquire.ts +++ b/src/tryAcquire.ts @@ -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'; @@ -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()); } diff --git a/src/withTimeout.ts b/src/withTimeout.ts index 77e232f..6096cd0 100644 --- a/src/withTimeout.ts +++ b/src/withTimeout.ts @@ -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 => { let weight: number | undefined; @@ -24,7 +24,7 @@ export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: const handle = setTimeout(() => { isTimeout = true; - reject(timeoutError); + reject(timeoutError ?? new TimeoutError()); }, timeout); try { @@ -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) diff --git a/test/mutex.ts b/test/mutex.ts index d945209..bb6fe92 100644 --- a/test/mutex.ts +++ b/test/mutex.ts @@ -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'; @@ -191,7 +191,7 @@ 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(); @@ -199,8 +199,8 @@ export const mutexSuite = (factory: (cancelError?: Error) => MutexInterface): vo 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 () => { diff --git a/test/semaphoreSuite.ts b/test/semaphoreSuite.ts index c75a179..437c9ef 100644 --- a/test/semaphoreSuite.ts +++ b/test/semaphoreSuite.ts @@ -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'; @@ -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(); @@ -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 () => { diff --git a/test/tryAcquire.ts b/test/tryAcquire.ts index dd69a56..310bc74 100644 --- a/test/tryAcquire.ts +++ b/test/tryAcquire.ts @@ -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'; @@ -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(); @@ -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 () => { @@ -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(); @@ -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 () => { diff --git a/test/withTimeout.ts b/test/withTimeout.ts index 1603655..d4194d9 100644 --- a/test/withTimeout.ts +++ b/test/withTimeout.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import { InstalledClock, install } from '@sinonjs/fake-timers'; -import { E_TIMEOUT } from './../src/errors'; +import { TimeoutError } from './../src/errors'; import Mutex from '../src/Mutex'; import MutexInterface from '../src/MutexInterface'; import Semaphore from '../src/Semaphore'; @@ -59,7 +59,7 @@ suite('withTimeout', () => { assert.strictEqual(flag, true); }); - test('runExclusive rejects with E_TIMEOUT if no error is specified', async () => { + test('runExclusive rejects with TimeoutError if no error is specified', async () => { const mutex = withTimeout(new Mutex(), 100); mutex.acquire().then((release) => setTimeout(release, 150)); @@ -68,7 +68,7 @@ suite('withTimeout', () => { await clock.tickAsync(110); - await assert.rejects(result, E_TIMEOUT); + await assert.rejects(result, (err: unknown) => err instanceof TimeoutError); }); test('runExclusive does not run the callback if timeout is exceeded', async () => { @@ -194,7 +194,7 @@ suite('withTimeout', () => { await assert.rejects(result, error); }); - test('runExclusive rejects with E_TIMEOUT if no error is specified', async () => { + test('runExclusive rejects with TimeoutError if no error is specified', async () => { const semaphore = withTimeout(new Semaphore(2), 0); semaphore.acquire(); @@ -205,7 +205,7 @@ suite('withTimeout', () => { await clock.tickAsync(110); - await assert.rejects(result, E_TIMEOUT); + await assert.rejects(result, (err: unknown) => err instanceof TimeoutError); }); test('runExclusive does not run the callback if timeout is exceeded', async () => {