├── .github └── workflows │ └── build_and_test.yaml ├── .gitignore ├── .mocharc.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── Mutex.ts ├── MutexInterface.ts ├── Semaphore.ts ├── SemaphoreInterface.ts ├── errors.ts ├── index.ts ├── tryAcquire.ts └── withTimeout.ts ├── test ├── mutex.ts ├── semaphore.ts ├── semaphoreSuite.ts ├── tryAcquire.ts ├── util.ts └── withTimeout.ts ├── tsconfig.es6.json ├── tsconfig.json ├── tsconfig.mjs.json └── yarn.lock /.github/workflows/build_and_test.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Tests 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build_and_test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 20.x 13 | - run: yarn install 14 | - run: yarn build 15 | - run: yarn test 16 | - name: coveralls 17 | uses: coverallsapp/github-action@master 18 | with: 19 | github-token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | es6 4 | mjs 5 | index.mjs 6 | coverage 7 | .nyc_output 8 | package.lock 9 | .vscode/launch.json 10 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register", 3 | "reporter": "spec", 4 | "ui": "tdd" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.formatOnSave": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.0 - 2024/03/11 4 | 5 | * Support priority queueing for mutexes and semaphores. A huge "thank you" 6 | goes to @dmurvihill who added this feature. 7 | * Update dependencies. 8 | 9 | ## 0.4.1 - 2024/01/17 10 | 11 | * Expand documentation and fix a few errors. 12 | * Clear timeout after acquiring a lock in `withTimeout`. 13 | * Thanks to AkatQuas and aryzing for their contributions. 14 | * Update dependencies. 15 | 16 | ## 0.4.0 - 2022/09/02 17 | 18 | This is a full rewrite of the core implementation. 19 | 20 | * Allow negative values for semaphores. 21 | * Allow weights for `semaphore.acquire` and `semaphore.runExclusive`. 22 | A waiter will be dispatched once the value of the semaphore is greater or 23 | equal to its weight. 24 | * Add `semaphore.getValue` and `semaphore.setValue`. 25 | * Allow weights for `semaphore.waitForUnlock`. The promise will only resolve 26 | once the value of the semaphore is greater or equal to its weight. 27 | * Only resolve `waitForUnlock` once no waiters remain (fixes #52). 28 | * `waitForUnlock` times out if the `withTimeout` decorator is used. 29 | 30 | ## 0.3.2 - 2021/08/30 31 | 32 | * Add `waitForUnlock` for waiting until a mutex/semaphore is free for locking, 33 | thanks to Jason Gore. 34 | 35 | ## 0.3.1 - 2021/02/22 36 | 37 | * `withTimeout`: make Jest happy and cancel timer when the mutex is acquired. 38 | Thanks to cantoine for the PR. 39 | 40 | ## 0.3.0 - 2021/02/05 41 | 42 | * Deprecate `Mutex::release` / `Semaphore::release` and remove them from the 43 | documentation. The methods are still available in 0.3.x, but will be removed in 44 | 0.4.0. 45 | 46 | I don't like breaking existing APIs, but using those methods is inherently 47 | dangerous as they can accidentally release locks acquired in a completely 48 | different place. Furthermore, they are mostly useless for semaphores. I consider 49 | adding them an unfortunate mistake on my end. 50 | 51 | A safe alternative is the usage of `runExclusive` which allows to execute 52 | blocks exclusively and automatically manages acquiring and releasing the 53 | mutex or semaphore. 54 | * Add `Mutex::cancel` / `Semaphore::cancel` for rejecting all currently pending 55 | locks. 56 | * Add `tryAcquire` decorator for lock-or-fail semantics. 57 | 58 | ## 0.2.6 - 2020/11/28 59 | 60 | * Fix a nasty [bug](https://github.com/DirtyHairy/async-mutex/issues/27) related to 61 | consecutive calls to `Mutex::release`. 62 | 63 | ## 0.2.5 - 2020/11/28 64 | 65 | * Nothing new thanks to NPM. Go away. Install 0.2.6. 66 | 67 | ## 0.2.4 - 2020/07/09 68 | 69 | * Calling Semaphore::release on a semaphore with concurrency > 1 will not work 70 | as expected; throw an exception in this case 71 | * Make the warning on using Semaphore::release and Mutex::release more prominent 72 | 73 | ## 0.2.3 - 2020/06/18 74 | 75 | * Add alternate Semaphore::release and Mutex::release API 76 | * Work around build warnings with react native (and probably other bundlers) 77 | 78 | ## 0.2.2 - 2020/04/15 79 | 80 | * Improve compatibility with older versions of node 13, thanks to @josemiguelmelo 81 | 82 | ## 0.2.1 - 2020/04/06 83 | 84 | * Remove sourcemaps 85 | 86 | ## 0.2.0 - 2020/04/06 87 | 88 | * Add a `Semaphore`, reimplement `Mutex` on top of it 89 | * Add a `withTimeout` decorator that limits the time the program waits 90 | for the mutex or semaphore to become available 91 | * Support native ES6 imports on Node >= 12 92 | * Provide an ES6 module entrypoint for ES6 aware bundlers 93 | * Dependency bump 94 | * Switch from TSlint to ESlint 95 | * Enable code coverage in tests 96 | 97 | ## 0.1.4 - 2019/09/26 98 | 99 | * Documentation updates (thanks to hmil and 0xflotus) 100 | * Update build dependencies 101 | 102 | ## 0.1.3 - 2017/09/17 103 | 104 | * Move deps to devDependencies (thanks to Meirion Hughes for the PR) 105 | * Upgrade deps 106 | 107 | ## 0.1.2 - 2017/06/29 108 | 109 | * Move to yarn 110 | * Add tslint 111 | * Switch tests to use ES6 112 | * Add isLocked() 113 | 114 | ## 0.1.1 - 2017/01/04 115 | 116 | * Fix documentation for `acquire` 117 | 118 | ## 0.1.0 - 2016/10/13 119 | 120 | * Initial release 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Christian Speckner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status](https://github.com/DirtyHairy/async-mutex/workflows/Build%20and%20Tests/badge.svg)](https://github.com/DirtyHairy/async-mutex/actions?query=workflow%3A%22Build+and+Tests%22) 2 | [![NPM version](https://badge.fury.io/js/async-mutex.svg)](https://badge.fury.io/js/async-mutex) 3 | [![Coverage Status](https://coveralls.io/repos/github/DirtyHairy/async-mutex/badge.svg?branch=master)](https://coveralls.io/github/DirtyHairy/async-mutex?branch=master) 4 | 5 | # What is it? 6 | 7 | This package implements primitives for synchronizing asynchronous operations in 8 | Javascript. 9 | 10 | ## Mutex 11 | 12 | The term "mutex" usually refers to a data structure used to synchronize 13 | concurrent processes running on different threads. For example, before accessing 14 | a non-threadsafe resource, a thread will lock the mutex. This is guaranteed 15 | to block the thread until no other thread holds a lock on the mutex and thus 16 | enforces exclusive access to the resource. Once the operation is complete, the 17 | thread releases the lock, allowing other threads to acquire a lock and access the 18 | resource. 19 | 20 | While Javascript is strictly single-threaded, the asynchronous nature of its 21 | execution model allows for race conditions that require similar synchronization 22 | primitives. Consider for example a library communicating with a web worker that 23 | needs to exchange several subsequent messages with the worker in order to achieve 24 | a task. As these messages are exchanged in an asynchronous manner, it is perfectly 25 | possible that the library is called again during this process. Depending on the 26 | way state is handled during the async process, this will lead to race conditions 27 | that are hard to fix and even harder to track down. 28 | 29 | This library solves the problem by applying the concept of mutexes to Javascript. 30 | Locking the mutex will return a promise that resolves once the mutex becomes 31 | available. Once the async process is complete (usually taking multiple 32 | spins of the event loop), a callback supplied to the caller should be called in order 33 | to release the mutex, allowing the next scheduled worker to execute. 34 | 35 | # Semaphore 36 | 37 | Imagine a situation where you need to control access to several instances of 38 | a shared resource. For example, you might want to distribute images between several 39 | worker processes that perform transformations, or you might want to create a web 40 | crawler that performs a defined number of requests in parallel. 41 | 42 | A semaphore is a data structure that is initialized with an arbitrary integer value and that 43 | can be locked multiple times. 44 | As long as the semaphore value is positive, locking it will return the current value 45 | and the locking process will continue execution immediately; the semaphore will 46 | be decremented upon locking. Releasing the lock will increment the semaphore again. 47 | 48 | Once the semaphore has reached zero, the next process that attempts to acquire a lock 49 | will be suspended until another process releases its lock and this increments the semaphore 50 | again. 51 | 52 | This library provides a semaphore implementation for Javascript that is similar to the 53 | mutex implementation described above. 54 | 55 | # How to use it? 56 | 57 | ## Installation 58 | 59 | You can install the library into your project via npm 60 | 61 | npm install async-mutex 62 | 63 | The library is written in TypeScript and will work in any environment that 64 | supports ES5, ES6 promises and `Array.isArray`. On ancient browsers, 65 | a shim can be used (e.g. [core-js](https://github.com/zloirock/core-js)). 66 | No external typings are required for using this library with 67 | TypeScript (version >= 2). 68 | 69 | Starting with Node 12.16 and 13.7, native ES6 style imports are supported. 70 | 71 | **WARNING:** Node 13 versions < 13.2.0 fail to import this package correctly. 72 | Node 12 and earlier are fine, as are newer versions of Node 13. 73 | 74 | ## Importing 75 | 76 | **CommonJS:** 77 | ```javascript 78 | var Mutex = require('async-mutex').Mutex; 79 | var Semaphore = require('async-mutex').Semaphore; 80 | var withTimeout = require('async-mutex').withTimeout; 81 | ``` 82 | 83 | **ES6:** 84 | ```javascript 85 | import {Mutex, Semaphore, withTimeout} from 'async-mutex'; 86 | ``` 87 | 88 | **TypeScript:** 89 | ```typescript 90 | import {Mutex, MutexInterface, Semaphore, SemaphoreInterface, withTimeout} from 'async-mutex'; 91 | ``` 92 | 93 | With the latest version of Node, native ES6 style imports are supported. 94 | 95 | ## Mutex API 96 | 97 | ### Creating 98 | 99 | ```typescript 100 | const mutex = new Mutex(); 101 | ``` 102 | 103 | Create a new mutex. 104 | 105 | ### Synchronized code execution 106 | 107 | Promise style: 108 | ```typescript 109 | mutex 110 | .runExclusive(() => { 111 | // ... 112 | }) 113 | .then((result) => { 114 | // ... 115 | }); 116 | ``` 117 | 118 | async/await: 119 | ```typescript 120 | await mutex.runExclusive(async () => { 121 | // ... 122 | }); 123 | ``` 124 | 125 | `runExclusive` schedules the supplied callback to be run once the mutex is unlocked. 126 | The function may return a promise. Once the promise is resolved or rejected (or immediately after 127 | execution if an immediate value was returned), 128 | the mutex is released. `runExclusive` returns a promise that adopts the state of the function result. 129 | 130 | The mutex is released and the result rejected if an exception occurs during execution 131 | of the callback. 132 | 133 | ### Manual locking / releasing 134 | 135 | Promise style: 136 | ```typescript 137 | mutex 138 | .acquire() 139 | .then(function(release) { 140 | // ... 141 | 142 | release(); 143 | }); 144 | ``` 145 | 146 | async/await: 147 | ```typescript 148 | const release = await mutex.acquire(); 149 | try { 150 | // ... 151 | } finally { 152 | release(); 153 | } 154 | ``` 155 | 156 | `acquire` returns an (ES6) promise that will resolve as soon as the mutex is 157 | available. The promise resolves with a function `release` that 158 | must be called once the mutex should be released again. The `release` callback 159 | is idempotent. 160 | 161 | **IMPORTANT:** Failure to call `release` will hold the mutex locked and will 162 | likely deadlock the application. Make sure to call `release` under all circumstances 163 | and handle exceptions accordingly. 164 | 165 | ### Unscoped release 166 | 167 | As an alternative to calling the `release` callback returned by `acquire`, the mutex 168 | can be released by calling `release` directly on it: 169 | 170 | ```typescript 171 | mutex.release(); 172 | ``` 173 | 174 | ### Checking whether the mutex is locked 175 | 176 | ```typescript 177 | mutex.isLocked(); 178 | ``` 179 | 180 | ### Cancelling pending locks 181 | 182 | Pending locks can be cancelled by calling `cancel()` on the mutex. This will reject 183 | all pending locks with `E_CANCELED`: 184 | 185 | Promise style: 186 | ```typescript 187 | import {E_CANCELED} from 'async-mutex'; 188 | 189 | mutex 190 | .runExclusive(() => { 191 | // ... 192 | }) 193 | .then(() => { 194 | // ... 195 | }) 196 | .catch(e => { 197 | if (e === E_CANCELED) { 198 | // ... 199 | } 200 | }); 201 | ``` 202 | 203 | async/await: 204 | ```typescript 205 | import {E_CANCELED} from 'async-mutex'; 206 | 207 | try { 208 | await mutex.runExclusive(() => { 209 | // ... 210 | }); 211 | } catch (e) { 212 | if (e === E_CANCELED) { 213 | // ... 214 | } 215 | } 216 | ``` 217 | 218 | This works with `acquire`, too: 219 | if `acquire` is used for locking, the resulting promise will reject with `E_CANCELED`. 220 | 221 | The error that is thrown can be customized by passing a different error to the `Mutex` 222 | constructor: 223 | 224 | ```typescript 225 | const mutex = new Mutex(new Error('fancy custom error')); 226 | ``` 227 | 228 | Note that while all pending locks are cancelled, a currently held lock will not be 229 | revoked. In consequence, the mutex may not be available even after `cancel()` has been called. 230 | 231 | ### Waiting until the mutex is available 232 | 233 | You can wait until the mutex is available without locking it by calling `waitForUnlock()`. 234 | This will return a promise that resolve once the mutex can be acquired again. This operation 235 | will not lock the mutex, and there is no guarantee that the mutex will still be available 236 | once an async barrier has been encountered. 237 | 238 | Promise style: 239 | ```typescript 240 | mutex 241 | .waitForUnlock() 242 | .then(() => { 243 | // ... 244 | }); 245 | ``` 246 | 247 | Async/await: 248 | ```typescript 249 | await mutex.waitForUnlock(); 250 | // ... 251 | ``` 252 | 253 | 254 | ## Semaphore API 255 | 256 | ### Creating 257 | 258 | ```typescript 259 | const semaphore = new Semaphore(initialValue); 260 | ``` 261 | 262 | Creates a new semaphore. `initialValue` is an arbitrary integer that defines the 263 | initial value of the semaphore. 264 | 265 | ### Synchronized code execution 266 | 267 | Promise style: 268 | ```typescript 269 | semaphore 270 | .runExclusive(function(value) { 271 | // ... 272 | }) 273 | .then(function(result) { 274 | // ... 275 | }); 276 | ``` 277 | 278 | async/await: 279 | ```typescript 280 | await semaphore.runExclusive(async (value) => { 281 | // ... 282 | }); 283 | ``` 284 | 285 | `runExclusive` schedules the supplied callback to be run once the semaphore is available. 286 | The callback will receive the current value of the semaphore as its argument. 287 | The function may return a promise. Once the promise is resolved or rejected (or immediately after 288 | execution if an immediate value was returned), 289 | the semaphore is released. `runExclusive` returns a promise that adopts the state of the function result. 290 | 291 | The semaphore is released and the result rejected if an exception occurs during execution 292 | of the callback. 293 | 294 | `runExclusive` accepts a first optional argument `weight`. Specifying a `weight` will decrement the 295 | semaphore by the specified value, and the callback will only be invoked once the semaphore's 296 | value greater or equal to `weight`. 297 | 298 | `runExclusive` accepts a second optional argument `priority`. Specifying a greater value for `priority` 299 | tells the scheduler to run this task before other tasks. `priority` can be any real number. The default 300 | is zero. 301 | 302 | ### Manual locking / releasing 303 | 304 | Promise style: 305 | ```typescript 306 | semaphore 307 | .acquire() 308 | .then(function([value, release]) { 309 | // ... 310 | 311 | release(); 312 | }); 313 | ``` 314 | 315 | async/await: 316 | ```typescript 317 | const [value, release] = await semaphore.acquire(); 318 | try { 319 | // ... 320 | } finally { 321 | release(); 322 | } 323 | ``` 324 | 325 | `acquire` returns an (ES6) promise that will resolve as soon as the semaphore is 326 | available. The promise resolves to an array with the 327 | first entry being the current value of the semaphore, and the second value a 328 | function that must be called to release the semaphore once the critical operation 329 | has completed. The `release` callback is idempotent. 330 | 331 | **IMPORTANT:** Failure to call `release` will hold the semaphore locked and will 332 | likely deadlock the application. Make sure to call `release` under all circumstances 333 | and handle exceptions accordingly. 334 | 335 | `acquire` accepts a first optional argument `weight`. Specifying a `weight` will decrement the 336 | semaphore by the specified value, and the semaphore will only be acquired once its 337 | value is greater or equal to `weight`. 338 | 339 | `acquire` accepts a second optional argument `priority`. Specifying a greater value for `priority` 340 | tells the scheduler to release the semaphore to the caller before other callers. `priority` can be 341 | any real number. The default is zero. 342 | 343 | ### Unscoped release 344 | 345 | As an alternative to calling the `release` callback returned by `acquire`, the semaphore 346 | can be released by calling `release` directly on it: 347 | 348 | ```typescript 349 | semaphore.release(); 350 | ``` 351 | 352 | `release` accepts an optional argument `weight` and increments the semaphore accordingly. 353 | 354 | **IMPORTANT:** Releasing a previously acquired semaphore with the releaser that was 355 | returned by acquire will automatically increment the semaphore by the correct weight. If 356 | you release by calling the unscoped `release` you have to supply the correct weight 357 | yourself! 358 | 359 | ### Getting the semaphore value 360 | 361 | ```typescript 362 | semaphore.getValue() 363 | ``` 364 | 365 | ### Checking whether the semaphore is locked 366 | 367 | ```typescript 368 | semaphore.isLocked(); 369 | ``` 370 | 371 | The semaphore is considered to be locked if its value is either zero or negative. 372 | 373 | ### Setting the semaphore value 374 | 375 | The value of a semaphore can be set directly to a desired value. A positive value will 376 | cause the semaphore to schedule any pending waiters accordingly. 377 | 378 | ```typescript 379 | semaphore.setValue(); 380 | ``` 381 | 382 | ### Cancelling pending locks 383 | 384 | Pending locks can be cancelled by calling `cancel()` on the semaphore. This will reject 385 | all pending locks with `E_CANCELED`: 386 | 387 | Promise style: 388 | ```typescript 389 | import {E_CANCELED} from 'async-mutex'; 390 | 391 | semaphore 392 | .runExclusive(() => { 393 | // ... 394 | }) 395 | .then(() => { 396 | // ... 397 | }) 398 | .catch(e => { 399 | if (e === E_CANCELED) { 400 | // ... 401 | } 402 | }); 403 | ``` 404 | 405 | async/await: 406 | ```typescript 407 | import {E_CANCELED} from 'async-mutex'; 408 | 409 | try { 410 | await semaphore.runExclusive(() => { 411 | // ... 412 | }); 413 | } catch (e) { 414 | if (e === E_CANCELED) { 415 | // ... 416 | } 417 | } 418 | ``` 419 | 420 | This works with `acquire`, too: 421 | if `acquire` is used for locking, the resulting promise will reject with `E_CANCELED`. 422 | 423 | The error that is thrown can be customized by passing a different error to the `Semaphore` 424 | constructor: 425 | 426 | ```typescript 427 | const semaphore = new Semaphore(2, new Error('fancy custom error')); 428 | ``` 429 | 430 | Note that while all pending locks are cancelled, any currently held locks will not be 431 | revoked. In consequence, the semaphore may not be available even after `cancel()` has been called. 432 | 433 | ### Waiting until the semaphore is available 434 | 435 | You can wait until the semaphore is available without locking it by calling `waitForUnlock()`. 436 | This will return a promise that resolve once the semaphore can be acquired again. This operation 437 | will not lock the semaphore, and there is no guarantee that the semaphore will still be available 438 | once an async barrier has been encountered. 439 | 440 | Promise style: 441 | ```typescript 442 | semaphore 443 | .waitForUnlock() 444 | .then(() => { 445 | // ... 446 | }); 447 | ``` 448 | 449 | Async/await: 450 | ```typescript 451 | await semaphore.waitForUnlock(); 452 | // ... 453 | ``` 454 | 455 | `waitForUnlock` accepts optional arguments `weight` and `priority`. The promise will resolve as soon 456 | as it is possible to `acquire` the semaphore with the given weight and priority. Scheduled tasks with 457 | the greatest `priority` values execute first. 458 | 459 | 460 | ## Limiting the time waiting for a mutex or semaphore to become available 461 | 462 | Sometimes it is desirable to limit the time a program waits for a mutex or 463 | semaphore to become available. The `withTimeout` decorator can be applied 464 | to both semaphores and mutexes and changes the behavior of `acquire` and 465 | `runExclusive` accordingly. 466 | 467 | ```typescript 468 | import {withTimeout, E_TIMEOUT} from 'async-mutex'; 469 | 470 | const mutexWithTimeout = withTimeout(new Mutex(), 100); 471 | const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100); 472 | ``` 473 | 474 | The API of the decorated mutex or semaphore is unchanged. 475 | 476 | The second argument of `withTimeout` is the timeout in milliseconds. After the 477 | timeout is exceeded, the promise returned by `acquire` and `runExclusive` will 478 | reject with `E_TIMEOUT`. The latter will not run the provided callback in case 479 | of an timeout. 480 | 481 | The third argument of `withTimeout` is optional and can be used to 482 | customize the error with which the promise is rejected. 483 | 484 | ```typescript 485 | const mutexWithTimeout = withTimeout(new Mutex(), 100, new Error('new fancy error')); 486 | const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100, new Error('new fancy error')); 487 | ``` 488 | 489 | ### Failing early if the mutex or semaphore is not available 490 | 491 | A shortcut exists for the case where you do not want to wait for a lock to 492 | be available at all. The `tryAcquire` decorator can be applied to both mutexes 493 | and semaphores and changes the behavior of `acquire` and `runExclusive` to 494 | immediately throw `E_ALREADY_LOCKED` if the mutex is not available. 495 | 496 | Promise style: 497 | ```typescript 498 | import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex'; 499 | 500 | tryAcquire(semaphoreOrMutex) 501 | .runExclusive(() => { 502 | // ... 503 | }) 504 | .then(() => { 505 | // ... 506 | }) 507 | .catch(e => { 508 | if (e === E_ALREADY_LOCKED) { 509 | // ... 510 | } 511 | }); 512 | ``` 513 | 514 | async/await: 515 | ```typescript 516 | import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex'; 517 | 518 | try { 519 | await tryAcquire(semaphoreOrMutex).runExclusive(() => { 520 | // ... 521 | }); 522 | } catch (e) { 523 | if (e === E_ALREADY_LOCKED) { 524 | // ... 525 | } 526 | } 527 | ``` 528 | 529 | Again, the error can be customized by providing a custom error as second argument to 530 | `tryAcquire`. 531 | 532 | ```typescript 533 | tryAcquire(semaphoreOrMutex, new Error('new fancy error')) 534 | .runExclusive(() => { 535 | // ... 536 | }); 537 | ``` 538 | # License 539 | 540 | Feel free to use this library under the conditions of the MIT license. 541 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-mutex", 3 | "version": "0.5.0", 4 | "description": "A mutex for guarding async workflows", 5 | "scripts": { 6 | "lint": "eslint src/**/*.ts test/**/*.ts", 7 | "build": "tsc && tsc -p tsconfig.es6.json && tsc -p tsconfig.mjs.json && rollup -o index.mjs mjs/index.js", 8 | "prepublishOnly": "yarn test && yarn build", 9 | "test": "yarn lint && nyc --reporter=text --reporter=html --reporter=lcov mocha test/*.ts", 10 | "coveralls": "cat ./coverage/lcov.info | coveralls" 11 | }, 12 | "author": "Christian Speckner (https://github.com/DirtyHairy/)", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/DirtyHairy/async-mutex" 17 | }, 18 | "prettier": { 19 | "printWidth": 120, 20 | "tabWidth": 4, 21 | "singleQuote": true, 22 | "parser": "typescript" 23 | }, 24 | "importSort": { 25 | ".js, .jsx, .ts, .tsx": { 26 | "style": "eslint", 27 | "parser": "typescript" 28 | } 29 | }, 30 | "eslintConfig": { 31 | "root": true, 32 | "parser": "@typescript-eslint/parser", 33 | "plugins": [ 34 | "@typescript-eslint" 35 | ], 36 | "extends": [ 37 | "eslint:recommended", 38 | "plugin:@typescript-eslint/eslint-recommended", 39 | "plugin:@typescript-eslint/recommended" 40 | ], 41 | "rules": { 42 | "eqeqeq": "error", 43 | "@typescript-eslint/no-namespace": "off", 44 | "no-async-promise-executor": "off" 45 | } 46 | }, 47 | "keywords": [ 48 | "mutex", 49 | "async" 50 | ], 51 | "files": [ 52 | "lib", 53 | "es6", 54 | "index.mjs" 55 | ], 56 | "devDependencies": { 57 | "@sinonjs/fake-timers": "^11.2.2", 58 | "@types/mocha": "^10.0.6", 59 | "@types/node": "^20.11.25", 60 | "@types/sinonjs__fake-timers": "^8.1.2", 61 | "@typescript-eslint/eslint-plugin": "^7.2.0", 62 | "@typescript-eslint/parser": "^7.2.0", 63 | "coveralls": "^3.1.1", 64 | "eslint": "^8.57.0", 65 | "import-sort-style-eslint": "^6.0.0", 66 | "mocha": "^10.3.0", 67 | "nyc": "^15.1.0", 68 | "prettier": "^3.2.5", 69 | "prettier-plugin-import-sort": "^0.0.7", 70 | "rollup": "^4.12.1", 71 | "ts-node": "^10.9.1", 72 | "typescript": "^5.4.2" 73 | }, 74 | "main": "lib/index.js", 75 | "module": "es6/index.js", 76 | "types": "lib/index.d.ts", 77 | "exports": { 78 | ".": { 79 | "import": "./index.mjs", 80 | "require": "./lib/index.js", 81 | "default": "./lib/index.js" 82 | }, 83 | "./package.json": "./package.json" 84 | }, 85 | "dependencies": { 86 | "tslib": "^2.4.0" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Mutex.ts: -------------------------------------------------------------------------------- 1 | import MutexInterface from './MutexInterface'; 2 | import Semaphore from './Semaphore'; 3 | 4 | class Mutex implements MutexInterface { 5 | constructor(cancelError?: Error) { 6 | this._semaphore = new Semaphore(1, cancelError); 7 | } 8 | 9 | async acquire(priority = 0): Promise { 10 | const [, releaser] = await this._semaphore.acquire(1, priority); 11 | 12 | return releaser; 13 | } 14 | 15 | runExclusive(callback: MutexInterface.Worker, priority = 0): Promise { 16 | return this._semaphore.runExclusive(() => callback(), 1, priority); 17 | } 18 | 19 | isLocked(): boolean { 20 | return this._semaphore.isLocked(); 21 | } 22 | 23 | waitForUnlock(priority = 0): Promise { 24 | return this._semaphore.waitForUnlock(1, priority); 25 | } 26 | 27 | release(): void { 28 | if (this._semaphore.isLocked()) this._semaphore.release(); 29 | } 30 | 31 | cancel(): void { 32 | return this._semaphore.cancel(); 33 | } 34 | 35 | private _semaphore: Semaphore; 36 | } 37 | 38 | export default Mutex; 39 | -------------------------------------------------------------------------------- /src/MutexInterface.ts: -------------------------------------------------------------------------------- 1 | interface MutexInterface { 2 | acquire(priority?: number): Promise; 3 | 4 | runExclusive(callback: MutexInterface.Worker, priority?: number): Promise; 5 | 6 | waitForUnlock(priority?: number): Promise; 7 | 8 | isLocked(): boolean; 9 | 10 | release(): void; 11 | 12 | cancel(): void; 13 | } 14 | 15 | namespace MutexInterface { 16 | export interface Releaser { 17 | (): void; 18 | } 19 | 20 | export interface Worker { 21 | (): Promise | T; 22 | } 23 | } 24 | 25 | export default MutexInterface; 26 | -------------------------------------------------------------------------------- /src/Semaphore.ts: -------------------------------------------------------------------------------- 1 | import { E_CANCELED } from './errors'; 2 | import SemaphoreInterface from './SemaphoreInterface'; 3 | 4 | 5 | interface Priority { 6 | priority: number; 7 | } 8 | 9 | interface QueueEntry { 10 | resolve(result: [number, SemaphoreInterface.Releaser]): void; 11 | reject(error: unknown): void; 12 | weight: number; 13 | priority: number; 14 | } 15 | 16 | interface Waiter { 17 | resolve(): void; 18 | priority: number; 19 | } 20 | 21 | class Semaphore implements SemaphoreInterface { 22 | constructor(private _value: number, private _cancelError: Error = E_CANCELED) {} 23 | 24 | acquire(weight = 1, priority = 0): Promise<[number, SemaphoreInterface.Releaser]> { 25 | if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`); 26 | 27 | return new Promise((resolve, reject) => { 28 | const task: QueueEntry = { resolve, reject, weight, priority }; 29 | const i = findIndexFromEnd(this._queue, (other) => priority <= other.priority); 30 | if (i === -1 && weight <= this._value) { 31 | // Needs immediate dispatch, skip the queue 32 | this._dispatchItem(task); 33 | } else { 34 | this._queue.splice(i + 1, 0, task); 35 | } 36 | }); 37 | } 38 | 39 | async runExclusive(callback: SemaphoreInterface.Worker, weight = 1, priority = 0): Promise { 40 | const [value, release] = await this.acquire(weight, priority); 41 | 42 | try { 43 | return await callback(value); 44 | } finally { 45 | release(); 46 | } 47 | } 48 | 49 | waitForUnlock(weight = 1, priority = 0): Promise { 50 | if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`); 51 | 52 | if (this._couldLockImmediately(weight, priority)) { 53 | return Promise.resolve(); 54 | } else { 55 | return new Promise((resolve) => { 56 | if (!this._weightedWaiters[weight - 1]) this._weightedWaiters[weight - 1] = []; 57 | insertSorted(this._weightedWaiters[weight - 1], { resolve, priority }); 58 | }); 59 | } 60 | } 61 | 62 | isLocked(): boolean { 63 | return this._value <= 0; 64 | } 65 | 66 | getValue(): number { 67 | return this._value; 68 | } 69 | 70 | setValue(value: number): void { 71 | this._value = value; 72 | this._dispatchQueue(); 73 | } 74 | 75 | release(weight = 1): void { 76 | if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`); 77 | 78 | this._value += weight; 79 | this._dispatchQueue(); 80 | } 81 | 82 | cancel(): void { 83 | this._queue.forEach((entry) => entry.reject(this._cancelError)); 84 | this._queue = []; 85 | } 86 | 87 | private _dispatchQueue(): void { 88 | this._drainUnlockWaiters(); 89 | while (this._queue.length > 0 && this._queue[0].weight <= this._value) { 90 | this._dispatchItem(this._queue.shift()!); 91 | this._drainUnlockWaiters(); 92 | } 93 | } 94 | 95 | private _dispatchItem(item: QueueEntry): void { 96 | const previousValue = this._value; 97 | this._value -= item.weight; 98 | item.resolve([previousValue, this._newReleaser(item.weight)]); 99 | } 100 | 101 | private _newReleaser(weight: number): () => void { 102 | let called = false; 103 | 104 | return () => { 105 | if (called) return; 106 | called = true; 107 | 108 | this.release(weight); 109 | }; 110 | } 111 | 112 | private _drainUnlockWaiters(): void { 113 | if (this._queue.length === 0) { 114 | for (let weight = this._value; weight > 0; weight--) { 115 | const waiters = this._weightedWaiters[weight - 1]; 116 | if (!waiters) continue; 117 | waiters.forEach((waiter) => waiter.resolve()); 118 | this._weightedWaiters[weight - 1] = []; 119 | } 120 | } else { 121 | const queuedPriority = this._queue[0].priority; 122 | for (let weight = this._value; weight > 0; weight--) { 123 | const waiters = this._weightedWaiters[weight - 1]; 124 | if (!waiters) continue; 125 | const i = waiters.findIndex((waiter) => waiter.priority <= queuedPriority); 126 | (i === -1 ? waiters : waiters.splice(0, i)) 127 | .forEach((waiter => waiter.resolve())); 128 | } 129 | } 130 | } 131 | 132 | private _couldLockImmediately(weight: number, priority: number) { 133 | return (this._queue.length === 0 || this._queue[0].priority < priority) && 134 | weight <= this._value; 135 | } 136 | 137 | private _queue: Array = []; 138 | private _weightedWaiters: Array> = []; 139 | } 140 | 141 | function insertSorted(a: T[], v: T) { 142 | const i = findIndexFromEnd(a, (other) => v.priority <= other.priority); 143 | a.splice(i + 1, 0, v); 144 | } 145 | 146 | function findIndexFromEnd(a: T[], predicate: (e: T) => boolean): number { 147 | for (let i = a.length - 1; i >= 0; i--) { 148 | if (predicate(a[i])) { 149 | return i; 150 | } 151 | } 152 | return -1; 153 | } 154 | 155 | export default Semaphore; 156 | -------------------------------------------------------------------------------- /src/SemaphoreInterface.ts: -------------------------------------------------------------------------------- 1 | interface SemaphoreInterface { 2 | acquire(weight?: number, priority?: number): Promise<[number, SemaphoreInterface.Releaser]>; 3 | 4 | runExclusive(callback: SemaphoreInterface.Worker, weight?: number, priority?: number): Promise; 5 | 6 | waitForUnlock(weight?: number, priority?: number): Promise; 7 | 8 | isLocked(): boolean; 9 | 10 | getValue(): number; 11 | 12 | setValue(value: number): void; 13 | 14 | release(weight?: number): void; 15 | 16 | cancel(): void; 17 | } 18 | 19 | namespace SemaphoreInterface { 20 | export interface Releaser { 21 | (): void; 22 | } 23 | 24 | export interface Worker { 25 | (value: number): Promise | T; 26 | } 27 | } 28 | 29 | export default SemaphoreInterface; 30 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export const E_TIMEOUT = new Error('timeout while waiting for mutex to become available'); 2 | export const E_ALREADY_LOCKED = new Error('mutex already locked'); 3 | export const E_CANCELED = new Error('request for lock canceled'); 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Mutex } from './Mutex'; 2 | export { default as MutexInterface } from './MutexInterface'; 3 | export { default as Semaphore } from './Semaphore'; 4 | export { default as SemaphoreInterface } from './SemaphoreInterface'; 5 | export { withTimeout } from './withTimeout'; 6 | export { tryAcquire } from './tryAcquire'; 7 | export * from './errors'; 8 | -------------------------------------------------------------------------------- /src/tryAcquire.ts: -------------------------------------------------------------------------------- 1 | import { E_ALREADY_LOCKED } from './errors'; 2 | import MutexInterface from './MutexInterface'; 3 | import SemaphoreInterface from './SemaphoreInterface'; 4 | import { withTimeout } from './withTimeout'; 5 | 6 | export function tryAcquire(mutex: MutexInterface, alreadyAcquiredError?: Error): MutexInterface; 7 | export function tryAcquire(semaphore: SemaphoreInterface, alreadyAcquiredError?: Error): SemaphoreInterface; 8 | // eslint-disable-next-lisne @typescript-eslint/explicit-module-boundary-types 9 | export function tryAcquire( 10 | sync: MutexInterface | SemaphoreInterface, 11 | alreadyAcquiredError = E_ALREADY_LOCKED 12 | ): typeof sync { 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | return withTimeout(sync as any, 0, alreadyAcquiredError); 15 | } 16 | -------------------------------------------------------------------------------- /src/withTimeout.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { E_TIMEOUT } from './errors'; 3 | import MutexInterface from './MutexInterface'; 4 | import SemaphoreInterface from './SemaphoreInterface'; 5 | 6 | export function withTimeout(mutex: MutexInterface, timeout: number, timeoutError?: Error): MutexInterface; 7 | export function withTimeout(semaphore: SemaphoreInterface, timeout: number, timeoutError?: Error): SemaphoreInterface; 8 | export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: number, timeoutError = E_TIMEOUT): any { 9 | return { 10 | acquire: (weightOrPriority?: number, priority?: number): Promise => { 11 | let weight: number | undefined; 12 | if (isSemaphore(sync)) { 13 | weight = weightOrPriority; 14 | } else { 15 | weight = undefined; 16 | priority = weightOrPriority; 17 | } 18 | if (weight !== undefined && weight <= 0) { 19 | throw new Error(`invalid weight ${weight}: must be positive`); 20 | } 21 | 22 | return new Promise(async (resolve, reject) => { 23 | let isTimeout = false; 24 | 25 | const handle = setTimeout(() => { 26 | isTimeout = true; 27 | reject(timeoutError); 28 | }, timeout); 29 | 30 | try { 31 | const ticket = await (isSemaphore(sync) 32 | ? sync.acquire(weight, priority) 33 | : sync.acquire(priority) 34 | ); 35 | if (isTimeout) { 36 | const release = Array.isArray(ticket) ? ticket[1] : ticket; 37 | 38 | release(); 39 | } else { 40 | clearTimeout(handle); 41 | resolve(ticket); 42 | } 43 | } catch (e) { 44 | if (!isTimeout) { 45 | clearTimeout(handle); 46 | 47 | reject(e); 48 | } 49 | } 50 | }); 51 | }, 52 | 53 | async runExclusive(callback: (value?: number) => Promise | T, weight?: number, priority?: number): Promise { 54 | let release: () => void = () => undefined; 55 | 56 | try { 57 | const ticket = await this.acquire(weight, priority); 58 | 59 | if (Array.isArray(ticket)) { 60 | release = ticket[1]; 61 | 62 | return await callback(ticket[0]); 63 | } else { 64 | release = ticket; 65 | 66 | return await callback(); 67 | } 68 | } finally { 69 | release(); 70 | } 71 | }, 72 | 73 | release(weight?: number): void { 74 | sync.release(weight); 75 | }, 76 | 77 | cancel(): void { 78 | return sync.cancel(); 79 | }, 80 | 81 | waitForUnlock: (weightOrPriority?: number, priority?: number): Promise => { 82 | let weight: number | undefined; 83 | if (isSemaphore(sync)) { 84 | weight = weightOrPriority; 85 | } else { 86 | weight = undefined; 87 | priority = weightOrPriority; 88 | } 89 | if (weight !== undefined && weight <= 0) { 90 | throw new Error(`invalid weight ${weight}: must be positive`); 91 | } 92 | 93 | return new Promise((resolve, reject) => { 94 | const handle = setTimeout(() => reject(timeoutError), timeout); 95 | (isSemaphore(sync) 96 | ? sync.waitForUnlock(weight, priority) 97 | : sync.waitForUnlock(priority) 98 | ).then(() => { 99 | clearTimeout(handle); 100 | resolve(); 101 | }); 102 | }); 103 | }, 104 | 105 | isLocked: (): boolean => sync.isLocked(), 106 | 107 | getValue: (): number => (sync as SemaphoreInterface).getValue(), 108 | 109 | setValue: (value: number) => (sync as SemaphoreInterface).setValue(value), 110 | }; 111 | } 112 | 113 | function isSemaphore(sync: SemaphoreInterface | MutexInterface): sync is SemaphoreInterface { 114 | return (sync as SemaphoreInterface).getValue !== undefined; 115 | } 116 | -------------------------------------------------------------------------------- /test/mutex.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import { InstalledClock, install } from '@sinonjs/fake-timers'; 4 | 5 | import { E_CANCELED } from '../src/errors'; 6 | import Mutex from '../src/Mutex'; 7 | import MutexInterface from '../src/MutexInterface'; 8 | import { withTimer } from './util'; 9 | 10 | export const mutexSuite = (factory: (cancelError?: Error) => MutexInterface): void => { 11 | let mutex: MutexInterface; 12 | let clock: InstalledClock; 13 | 14 | setup(() => { 15 | clock = install(); 16 | mutex = factory(); 17 | }); 18 | 19 | teardown(() => clock.uninstall()); 20 | 21 | test('ownership is exclusive', () => 22 | withTimer(clock, async () => { 23 | let flag = false; 24 | 25 | const release = await mutex.acquire(); 26 | 27 | setTimeout(() => { 28 | flag = true; 29 | release(); 30 | }, 50); 31 | 32 | assert(!flag); 33 | 34 | (await mutex.acquire())(); 35 | 36 | assert(flag); 37 | })); 38 | 39 | test('acquire unblocks the highest-priority task first', () => 40 | withTimer(clock, async () => { 41 | const values: number[] = []; 42 | 43 | // Scheduled immediately 44 | mutex.acquire(0).then((release) => { 45 | values.push(0); 46 | setTimeout(release, 100) 47 | }); 48 | 49 | // Low priority task 50 | mutex.acquire(-1).then((release) => { 51 | values.push(-1); 52 | setTimeout(release, 100) 53 | }); 54 | 55 | // High priority task; jumps the queue 56 | mutex.acquire(1).then((release) => { 57 | values.push(1); 58 | setTimeout(release, 100) 59 | }); 60 | 61 | await clock.runAllAsync(); 62 | assert.deepStrictEqual(values, [0, 1, -1]); 63 | }) 64 | ); 65 | 66 | test('runExclusive passes result (immediate)', async () => { 67 | assert.strictEqual(await mutex.runExclusive(() => 10), 10); 68 | }); 69 | 70 | test('runExclusive passes result (promise)', async () => { 71 | assert.strictEqual(await mutex.runExclusive(() => Promise.resolve(10)), 10); 72 | }); 73 | 74 | test('runExclusive passes rejection', async () => { 75 | await assert.rejects( 76 | mutex.runExclusive(() => Promise.reject(new Error('foo'))), 77 | new Error('foo') 78 | ); 79 | }); 80 | 81 | test('runExclusive passes exception', async () => { 82 | await assert.rejects( 83 | mutex.runExclusive(() => { 84 | throw new Error('foo'); 85 | }), 86 | new Error('foo') 87 | ); 88 | }); 89 | 90 | test('runExclusive is exclusive', () => 91 | withTimer(clock, async () => { 92 | let flag = false; 93 | 94 | mutex.runExclusive( 95 | () => 96 | new Promise((resolve) => 97 | setTimeout(() => { 98 | flag = true; 99 | resolve(undefined); 100 | }, 50) 101 | ) 102 | ); 103 | 104 | assert(!flag); 105 | 106 | await mutex.runExclusive(() => undefined); 107 | 108 | assert(flag); 109 | })); 110 | 111 | test('runExclusive unblocks the highest-priority task first', async () => { 112 | const values: number[] = []; 113 | mutex.runExclusive(() => { values.push(0); }, 0); 114 | mutex.runExclusive(() => { values.push(-1); }, -1); 115 | mutex.runExclusive(() => { values.push(+1); }, +1); 116 | await clock.runAllAsync(); 117 | assert.deepStrictEqual(values, [0, +1, -1]); 118 | }); 119 | 120 | test('exceptions during runExclusive do not leave mutex locked', async () => { 121 | let flag = false; 122 | 123 | mutex 124 | .runExclusive(() => { 125 | flag = true; 126 | throw new Error(); 127 | }) 128 | .then(undefined, () => undefined); 129 | 130 | assert(!flag); 131 | 132 | await mutex.runExclusive(() => undefined); 133 | 134 | assert(flag); 135 | }); 136 | 137 | test('new mutex is unlocked', () => { 138 | assert(!mutex.isLocked()); 139 | }); 140 | 141 | test('isLocked reflects the mutex state', async () => { 142 | const lock1 = mutex.acquire(), 143 | lock2 = mutex.acquire(); 144 | 145 | assert(mutex.isLocked()); 146 | 147 | const releaser1 = await lock1; 148 | 149 | assert(mutex.isLocked()); 150 | 151 | releaser1(); 152 | 153 | assert(mutex.isLocked()); 154 | 155 | const releaser2 = await lock2; 156 | 157 | assert(mutex.isLocked()); 158 | 159 | releaser2(); 160 | 161 | assert(!mutex.isLocked()); 162 | }); 163 | 164 | test('the release method releases a locked mutex', async () => { 165 | await mutex.acquire(); 166 | 167 | assert(mutex.isLocked()); 168 | 169 | mutex.release(); 170 | 171 | assert(!mutex.isLocked()); 172 | }); 173 | 174 | test('calling release on a unlocked mutex does not throw', () => { 175 | mutex.release(); 176 | }); 177 | 178 | test('multiple calls to release behave as expected', async () => { 179 | let v = 0; 180 | 181 | const run = async () => { 182 | await mutex.acquire(); 183 | 184 | v++; 185 | 186 | mutex.release(); 187 | }; 188 | 189 | await Promise.all([run(), run(), run()]); 190 | 191 | assert.strictEqual(v, 3); 192 | }); 193 | 194 | test('cancel rejects all pending locks witth E_CANCELED', async () => { 195 | await mutex.acquire(); 196 | 197 | const ticket = mutex.acquire(); 198 | const result = mutex.runExclusive(() => undefined); 199 | 200 | mutex.cancel(); 201 | 202 | await assert.rejects(ticket, E_CANCELED); 203 | await assert.rejects(result, E_CANCELED); 204 | }); 205 | 206 | test('cancel rejects with a custom error if provided', async () => { 207 | const err = new Error(); 208 | const mutex = factory(err); 209 | 210 | await mutex.acquire(); 211 | 212 | const ticket = mutex.acquire(); 213 | 214 | mutex.cancel(); 215 | 216 | await assert.rejects(ticket, err); 217 | }); 218 | 219 | test('a canceled waiter will not lock the mutex again', async () => { 220 | const release = await mutex.acquire(); 221 | 222 | mutex.acquire().then(undefined, () => undefined); 223 | mutex.cancel(); 224 | 225 | assert(mutex.isLocked()); 226 | 227 | release(); 228 | 229 | assert(!mutex.isLocked()); 230 | }); 231 | 232 | test('waitForUnlock does not block while the mutex has not been acquired', async () => { 233 | let taskCalls = 0; 234 | 235 | const awaitUnlockWrapper = async () => { 236 | await mutex.waitForUnlock(); 237 | taskCalls++; 238 | }; 239 | 240 | awaitUnlockWrapper(); 241 | awaitUnlockWrapper(); 242 | await clock.tickAsync(1); 243 | 244 | assert.strictEqual(taskCalls, 2); 245 | }); 246 | 247 | test('waitForUnlock blocks when the mutex has been acquired', async () => { 248 | let taskCalls = 0; 249 | 250 | const awaitUnlockWrapper = async () => { 251 | await mutex.waitForUnlock(); 252 | taskCalls++; 253 | }; 254 | 255 | mutex.acquire(); 256 | 257 | awaitUnlockWrapper(); 258 | awaitUnlockWrapper(); 259 | await clock.tickAsync(0); 260 | 261 | assert.strictEqual(taskCalls, 0); 262 | }); 263 | 264 | test('waitForUnlock unblocks after a release', async () => { 265 | let taskCalls = 0; 266 | 267 | const awaitUnlockWrapper = async () => { 268 | await mutex.waitForUnlock(); 269 | taskCalls++; 270 | }; 271 | 272 | const releaser = await mutex.acquire(); 273 | 274 | awaitUnlockWrapper(); 275 | awaitUnlockWrapper(); 276 | await clock.tickAsync(0); 277 | 278 | assert.strictEqual(taskCalls, 0); 279 | 280 | releaser(); 281 | 282 | await clock.tickAsync(0); 283 | 284 | assert.strictEqual(taskCalls, 2); 285 | }); 286 | 287 | test('waitForUnlock only unblocks when the mutex can actually be acquired again', async () => { 288 | mutex.acquire(); 289 | mutex.acquire(); 290 | 291 | let flag = false; 292 | mutex.waitForUnlock().then(() => (flag = true)); 293 | 294 | mutex.release(); 295 | await clock.tickAsync(0); 296 | 297 | assert.strictEqual(flag, false); 298 | 299 | mutex.release(); 300 | await clock.tickAsync(0); 301 | 302 | assert.strictEqual(flag, true); 303 | }); 304 | 305 | test('waitForUnlock unblocks high-priority waiters before low-priority queued tasks', async () => { 306 | mutex.acquire(0); // Immediately scheduled 307 | mutex.acquire(0); // Waiting 308 | let flag = false; 309 | mutex.waitForUnlock(1).then(() => { flag = true; }); 310 | mutex.release(); 311 | await clock.tickAsync(0); 312 | assert.strictEqual(flag, true); 313 | }); 314 | 315 | test('waitForUnlock unblocks low-priority waiters after high-priority queued tasks', async () => { 316 | mutex.acquire(0); // Immediately scheduled 317 | mutex.acquire(0); // Waiting 318 | let flag = false; 319 | mutex.waitForUnlock(-1).then(() => { flag = true; }); 320 | mutex.release(); 321 | await clock.tickAsync(0); 322 | assert.strictEqual(flag, false); 323 | }); 324 | }; 325 | 326 | suite('Mutex', () => mutexSuite((e) => new Mutex(e))); 327 | -------------------------------------------------------------------------------- /test/semaphore.ts: -------------------------------------------------------------------------------- 1 | import Semaphore from '../src/Semaphore'; 2 | import { semaphoreSuite } from './semaphoreSuite'; 3 | 4 | suite('Semaphore', () => { 5 | semaphoreSuite((maxConcurrency: number, err?: Error) => new Semaphore(maxConcurrency, err)); 6 | }); 7 | -------------------------------------------------------------------------------- /test/semaphoreSuite.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import { InstalledClock, install } from '@sinonjs/fake-timers'; 4 | 5 | import { E_CANCELED } from '../src/errors'; 6 | import SemaphoreInterface from '../src/SemaphoreInterface'; 7 | import { withTimer } from './util'; 8 | 9 | export const semaphoreSuite = (factory: (maxConcurrency: number, err?: Error) => SemaphoreInterface): void => { 10 | let semaphore: SemaphoreInterface; 11 | let clock: InstalledClock; 12 | 13 | setup(() => { 14 | clock = install(); 15 | semaphore = factory(2); 16 | }); 17 | 18 | teardown(() => clock.uninstall()); 19 | 20 | test('acquire does not block while the semaphore has not reached zero', async () => { 21 | const values: Array = []; 22 | 23 | semaphore.acquire().then(([value]) => { 24 | values.push(value); 25 | }); 26 | semaphore.acquire().then(([value]) => { 27 | values.push(value); 28 | }); 29 | 30 | await clock.tickAsync(0); 31 | 32 | assert.deepStrictEqual(values.sort(), [1, 2]); 33 | }); 34 | 35 | test('acquire with weight does block while the semaphore has reached zero until it is released again', async () => { 36 | const values: Array = []; 37 | 38 | semaphore.acquire(2).then(([value, release]) => { 39 | values.push(value); 40 | setTimeout(release, 100); 41 | }); 42 | semaphore.acquire(1).then(([value]) => values.push(value)); 43 | 44 | await clock.tickAsync(0); 45 | 46 | assert.deepStrictEqual(values.sort(), [2]); 47 | 48 | await clock.runAllAsync(); 49 | 50 | assert.deepStrictEqual(values.sort(), [2, 2]); 51 | }); 52 | 53 | test('acquire unblocks high-priority tasks first', async () => { 54 | const values: Array = []; 55 | 56 | // priority=0; runs first because nothing else is waiting 57 | semaphore.acquire(2, 0).then(([, release]) => { 58 | values.push(0); 59 | setTimeout(release, 100); 60 | }); 61 | 62 | // priority=-1; queues first 63 | semaphore.acquire(2, -1).then(([, release]) => { 64 | values.push(-1); 65 | setTimeout(release, 100); 66 | }); 67 | 68 | // priority=+1; jumps ahead of priority=-1 69 | semaphore.acquire(2, +1).then(([, release]) => { 70 | values.push(+1); 71 | setTimeout(release, 100); 72 | }); 73 | 74 | await clock.runAllAsync(); 75 | assert.deepStrictEqual(values, [0, +1, -1]); 76 | }); 77 | 78 | test('acquire allows light high-priority tasks to skip the line', async () => { 79 | let executed = false; 80 | semaphore.acquire(3, 0); 81 | semaphore.acquire(1, 1).then(([, release]) => { 82 | executed = true; 83 | setTimeout(release, 100); 84 | }); 85 | await clock.runAllAsync(); 86 | assert.strictEqual(executed, true); 87 | }); 88 | 89 | test('acquire prioritizes high-priority tasks even if they are heavier', async () => { 90 | const values: Array = []; 91 | 92 | // two items with weight 1; runs first because nothing else is waiting 93 | semaphore.acquire(1, 0).then(([, release]) => { 94 | values.push(0); 95 | setTimeout(release, 100); 96 | }); 97 | semaphore.acquire(1, 0).then(([, release]) => { 98 | values.push(0); 99 | setTimeout(release, 100); 100 | }); 101 | 102 | // low-priority item with weight 1 103 | semaphore.acquire(1, -1).then(([, release]) => { 104 | values.push(-1); 105 | setTimeout(release, 100); 106 | }); 107 | 108 | // high-priority item with weight 2; should run before the others 109 | semaphore.acquire(2, +1).then(([, release]) => { 110 | values.push(+1); 111 | setTimeout(release, 100); 112 | }); 113 | 114 | await clock.runAllAsync(); 115 | assert.deepStrictEqual(values, [0, 0, +1, -1]); 116 | }); 117 | 118 | test('acquire allows heavy items to run eventually', async () => { 119 | let done = false; 120 | async function lightLoop() { 121 | while (!done) { 122 | const [,release] = await semaphore.acquire(1); 123 | await new Promise((resolve) => { setTimeout(resolve, 10); }); 124 | release(); 125 | } 126 | } 127 | lightLoop(); 128 | await clock.tickAsync(5); 129 | lightLoop(); 130 | semaphore.acquire(2).then(() => { done = true; }); 131 | await clock.tickAsync(10); 132 | await clock.tickAsync(10); 133 | assert.strictEqual(done, true); 134 | }); 135 | 136 | test('acquire blocks when the semaphore has reached zero until it is released again', async () => { 137 | const values: Array = []; 138 | 139 | semaphore.acquire().then(([value]) => values.push(value)); 140 | semaphore.acquire().then(([value, release]) => { 141 | values.push(value); 142 | setTimeout(release, 100); 143 | }); 144 | semaphore.acquire().then(([value]) => values.push(value)); 145 | 146 | await clock.tickAsync(0); 147 | 148 | assert.deepStrictEqual(values.sort(), [1, 2]); 149 | 150 | await clock.runAllAsync(); 151 | 152 | assert.deepStrictEqual(values.sort(), [1, 1, 2]); 153 | }); 154 | 155 | test('the semaphore increments again after a release', async () => { 156 | semaphore.acquire().then(([, release]) => setTimeout(release, 100)); 157 | semaphore.acquire().then(([, release]) => setTimeout(release, 200)); 158 | 159 | await clock.tickAsync(250); 160 | 161 | const [value] = await semaphore.acquire(); 162 | 163 | assert.strictEqual(value, 2); 164 | }); 165 | 166 | test('a semaphore can be initialized to negative values', async () => { 167 | semaphore = factory(-2); 168 | 169 | let value: number | undefined = undefined; 170 | semaphore.acquire().then(([x]) => { 171 | value = x; 172 | }); 173 | 174 | await clock.tickAsync(0); 175 | assert.strictEqual(value, undefined); 176 | 177 | semaphore.release(2); 178 | await clock.tickAsync(0); 179 | assert.strictEqual(value, undefined); 180 | 181 | semaphore.release(2); 182 | await clock.tickAsync(0); 183 | assert.strictEqual(value, 2); 184 | }); 185 | 186 | test('the releaser is idempotent', async () => { 187 | const values: Array = []; 188 | 189 | semaphore.acquire().then(([value, release]) => { 190 | values.push(value); 191 | setTimeout(() => { 192 | release(); 193 | release(); 194 | }, 50); 195 | }); 196 | 197 | semaphore.acquire().then(([value, release]) => { 198 | values.push(value); 199 | setTimeout(release, 100); 200 | }); 201 | 202 | await clock.tickAsync(10); 203 | 204 | semaphore.acquire().then(([value]) => values.push(value)); 205 | semaphore.acquire().then(([value]) => values.push(value)); 206 | 207 | await clock.tickAsync(10); 208 | 209 | assert.deepStrictEqual(values.sort(), [1, 2]); 210 | 211 | await clock.tickAsync(40); 212 | 213 | assert.deepStrictEqual(values.sort(), [1, 1, 2]); 214 | 215 | await clock.tickAsync(50); 216 | 217 | assert.deepStrictEqual(values.sort(), [1, 1, 1, 2]); 218 | }); 219 | 220 | test('the releaser increments by the correct weight', async () => { 221 | await semaphore.acquire(2); 222 | assert.strictEqual(semaphore.getValue(), 0); 223 | 224 | semaphore.release(2); 225 | assert.strictEqual(semaphore.getValue(), 2); 226 | 227 | await semaphore.acquire(); 228 | assert.strictEqual(semaphore.getValue(), 1); 229 | 230 | semaphore.release(); 231 | assert.strictEqual(semaphore.getValue(), 2); 232 | }); 233 | 234 | test('runExclusive passes semaphore value', async () => { 235 | let value = -1; 236 | 237 | semaphore.runExclusive((v) => (value = v)); 238 | 239 | await clock.tickAsync(0); 240 | 241 | assert.strictEqual(value, 2); 242 | }); 243 | 244 | test('runExclusive passes result (immediate)', async () => { 245 | assert.strictEqual(await semaphore.runExclusive(() => 10), 10); 246 | }); 247 | 248 | test('runExclusive passes result (promise)', async () => { 249 | assert.strictEqual(await semaphore.runExclusive(() => Promise.resolve(10)), 10); 250 | }); 251 | 252 | test('runExclusive passes rejection', async () => { 253 | await assert.rejects( 254 | semaphore.runExclusive(() => Promise.reject(new Error('foo'))), 255 | new Error('foo') 256 | ); 257 | }); 258 | 259 | test('runExclusive passes exception', async () => { 260 | await assert.rejects( 261 | semaphore.runExclusive(() => { 262 | throw new Error('foo'); 263 | }), 264 | new Error('foo') 265 | ); 266 | }); 267 | 268 | test('runExclusive is exclusive', () => 269 | withTimer(clock, async () => { 270 | let flag = false; 271 | 272 | semaphore.acquire(); 273 | 274 | semaphore.runExclusive( 275 | () => 276 | new Promise((resolve) => 277 | setTimeout(() => { 278 | flag = true; 279 | resolve(undefined); 280 | }, 50) 281 | ) 282 | ); 283 | 284 | assert(!flag); 285 | 286 | await semaphore.runExclusive(() => undefined); 287 | 288 | assert(flag); 289 | })); 290 | 291 | test('exceptions during runExclusive do not leave semaphore locked', async () => { 292 | let flag = false; 293 | 294 | semaphore.acquire(); 295 | 296 | semaphore 297 | .runExclusive(() => { 298 | flag = true; 299 | throw new Error(); 300 | }) 301 | .then(undefined, () => undefined); 302 | 303 | assert(!flag); 304 | 305 | await semaphore.runExclusive(() => undefined); 306 | 307 | assert(flag); 308 | }); 309 | 310 | test('runExclusive passes the correct weight', async () => { 311 | semaphore.runExclusive(() => undefined, 2); 312 | assert.strictEqual(semaphore.getValue(), 0); 313 | 314 | await clock.runAllAsync(); 315 | assert.strictEqual(semaphore.getValue(), 2); 316 | }); 317 | 318 | test('runExclusive executes high-priority tasks first', async () => { 319 | const values: number[] = []; 320 | semaphore.runExclusive(() => { values.push(0) }, 2); 321 | semaphore.runExclusive(() => { values.push(-1) }, 2, -1); 322 | semaphore.runExclusive(() => { values.push(+1) }, 2, +1); 323 | await clock.runAllAsync(); 324 | assert.deepStrictEqual(values, [0, +1, -1]); 325 | }); 326 | 327 | test('new semaphore is unlocked', () => { 328 | assert(!semaphore.isLocked()); 329 | }); 330 | 331 | test('isLocked reflects the semaphore state', async () => { 332 | const lock1 = semaphore.acquire(), 333 | lock2 = semaphore.acquire(); 334 | 335 | semaphore.acquire(); 336 | 337 | assert(semaphore.isLocked()); 338 | 339 | const [, releaser1] = await lock1; 340 | 341 | assert(semaphore.isLocked()); 342 | 343 | releaser1(); 344 | 345 | assert(semaphore.isLocked()); 346 | 347 | const [, releaser2] = await lock2; 348 | 349 | assert(semaphore.isLocked()); 350 | 351 | releaser2(); 352 | 353 | assert(!semaphore.isLocked()); 354 | }); 355 | 356 | test("getValue returns the Semaphore's value", () => { 357 | assert.strictEqual(semaphore.getValue(), 2); 358 | semaphore.acquire(); 359 | 360 | assert.strictEqual(semaphore.getValue(), 1); 361 | }); 362 | 363 | test('setValue sets the semaphore value and runs all applicable waiters', async () => { 364 | semaphore = factory(0); 365 | 366 | let flag1 = false; 367 | let flag2 = false; 368 | let flag3 = false; 369 | 370 | semaphore.acquire(1).then(() => (flag1 = true)); 371 | semaphore.acquire(2).then(() => (flag2 = true)); 372 | semaphore.acquire(4).then(() => (flag3 = true)); 373 | 374 | semaphore.setValue(3); 375 | 376 | await clock.runAllAsync(); 377 | 378 | assert.strictEqual(flag1, true); 379 | assert.strictEqual(flag2, true); 380 | assert.strictEqual(flag3, false); 381 | }); 382 | 383 | test('setValue works fine with isolated weights', async () => { 384 | let flag = false; 385 | semaphore.acquire(4).then(() => (flag = true)); 386 | semaphore.acquire(8); 387 | 388 | semaphore.setValue(4); 389 | await clock.tickAsync(1); 390 | 391 | assert.strictEqual(flag, true); 392 | }); 393 | 394 | test('the release method releases a locked semaphore', async () => { 395 | semaphore = factory(1); 396 | 397 | await semaphore.acquire(); 398 | assert(semaphore.isLocked()); 399 | 400 | semaphore.release(); 401 | 402 | assert(!semaphore.isLocked()); 403 | }); 404 | 405 | test('calling release on a unlocked semaphore does not throw', () => { 406 | semaphore = factory(1); 407 | 408 | semaphore.release(); 409 | }); 410 | 411 | test('cancel rejects all pending locks with E_CANCELED', async () => { 412 | await semaphore.acquire(); 413 | await semaphore.acquire(); 414 | 415 | const ticket = semaphore.acquire(); 416 | const result = semaphore.runExclusive(() => undefined); 417 | 418 | semaphore.cancel(); 419 | 420 | await assert.rejects(ticket, E_CANCELED); 421 | await assert.rejects(result, E_CANCELED); 422 | }); 423 | 424 | test('cancel rejects with a custom error if provided', async () => { 425 | const err = new Error(); 426 | const semaphore = factory(2, err); 427 | 428 | await semaphore.acquire(); 429 | await semaphore.acquire(); 430 | 431 | const ticket = semaphore.acquire(); 432 | 433 | semaphore.cancel(); 434 | 435 | await assert.rejects(ticket, err); 436 | }); 437 | 438 | test('a canceled waiter will not lock the semaphore again', async () => { 439 | const [, release] = await semaphore.acquire(2); 440 | 441 | semaphore.acquire().then(undefined, () => undefined); 442 | semaphore.cancel(); 443 | 444 | assert(semaphore.isLocked()); 445 | 446 | release(); 447 | 448 | assert(!semaphore.isLocked()); 449 | }); 450 | 451 | test('cancel works fine with isolated weights', () => { 452 | const ticket = semaphore.acquire(3); 453 | 454 | semaphore.cancel(); 455 | 456 | assert.rejects(ticket); 457 | }); 458 | 459 | test('waitForUnlock does not block while the semaphore has not reached zero', async () => { 460 | let taskCalls = 0; 461 | 462 | const awaitUnlockWrapper = async () => { 463 | await semaphore.waitForUnlock(); 464 | taskCalls++; 465 | }; 466 | 467 | awaitUnlockWrapper(); 468 | awaitUnlockWrapper(); 469 | await clock.tickAsync(1); 470 | 471 | assert.strictEqual(taskCalls, 2); 472 | }); 473 | 474 | test('waitForUnlock blocks when the semaphore has reached zero', async () => { 475 | let taskCalls = 0; 476 | 477 | const awaitUnlockWrapper = async () => { 478 | await semaphore.waitForUnlock(); 479 | taskCalls++; 480 | }; 481 | 482 | semaphore.acquire(); 483 | semaphore.acquire(); 484 | 485 | awaitUnlockWrapper(); 486 | awaitUnlockWrapper(); 487 | await clock.tickAsync(0); 488 | 489 | assert.strictEqual(taskCalls, 0); 490 | }); 491 | 492 | test('waitForUnlock unblocks after a release', async () => { 493 | let taskCalls = 0; 494 | 495 | const awaitUnlockWrapper = async () => { 496 | await semaphore.waitForUnlock(); 497 | taskCalls++; 498 | }; 499 | 500 | const lock = semaphore.acquire(); 501 | semaphore.acquire(); 502 | 503 | awaitUnlockWrapper(); 504 | awaitUnlockWrapper(); 505 | await clock.tickAsync(0); 506 | 507 | assert.strictEqual(taskCalls, 0); 508 | 509 | const [, releaser] = await lock; 510 | releaser(); 511 | await clock.tickAsync(0); 512 | 513 | assert.strictEqual(taskCalls, 2); 514 | }); 515 | 516 | test('waitForUnlock only unblocks if the configured weight can be acquired', async () => { 517 | await semaphore.acquire(2); 518 | 519 | let flag1 = false; 520 | let flag2 = false; 521 | 522 | semaphore.waitForUnlock(1).then(() => (flag1 = true)); 523 | semaphore.waitForUnlock(2).then(() => (flag2 = true)); 524 | 525 | semaphore.release(1); 526 | await clock.tickAsync(0); 527 | 528 | assert.deepStrictEqual([flag1, flag2], [true, false]); 529 | 530 | semaphore.release(1); 531 | await clock.tickAsync(0); 532 | 533 | assert.deepStrictEqual([flag1, flag2], [true, true]); 534 | }); 535 | 536 | test('waitForUnlock unblocks only high-priority waiters immediately', async () => { 537 | const calledBack: number[] = []; 538 | semaphore.acquire(3, 1); // A big heavy waiting task 539 | semaphore.waitForUnlock(1, 0).then(() => { calledBack.push(0); }); // Low priority 540 | semaphore.waitForUnlock(1, 2).then(() => { calledBack.push(2); }); // High priority 541 | semaphore.waitForUnlock(1, 1).then(() => { calledBack.push(1); }); // Queued behind the heavy task 542 | await clock.runAllAsync(); 543 | assert.deepStrictEqual(calledBack, [2]); 544 | }); 545 | 546 | test('waitForUnlock unblocks waiters of descending priority as the queue drains', async () => { 547 | let calledBack = false; 548 | let release: SemaphoreInterface.Releaser; 549 | 550 | semaphore.acquire(2, 2).then(([, r]) => { release = r; }); 551 | semaphore.acquire(2, 0).then(([, r]) => { setTimeout(r, 100); }); 552 | 553 | semaphore.waitForUnlock(2, 1).then(() => { calledBack = true; }); 554 | 555 | await clock.tickAsync(0); 556 | assert.strictEqual(calledBack, false); 557 | release!(); 558 | await clock.tickAsync(0); 559 | assert.strictEqual(calledBack, true); 560 | await clock.runAllAsync(); 561 | }); 562 | 563 | test('waitForUnlock resolves immediately when the queue is empty', async () => { 564 | let calledBack = false; 565 | semaphore.waitForUnlock(1).then(() => { calledBack = true; }); 566 | await clock.tickAsync(0); 567 | assert.strictEqual(calledBack, true); 568 | }); 569 | 570 | test('waitForUnlock only unblocks when the semaphore can actually be acquired again', async () => { 571 | semaphore.acquire(2); 572 | semaphore.acquire(2); 573 | 574 | let flag = false; 575 | semaphore.waitForUnlock().then(() => (flag = true)); 576 | 577 | semaphore.release(2); 578 | await clock.tickAsync(0); 579 | 580 | assert.strictEqual(flag, false); 581 | 582 | semaphore.release(2); 583 | await clock.tickAsync(0); 584 | 585 | assert.strictEqual(flag, true); 586 | }); 587 | 588 | test('trying to acquire with a negative weight throws', () => { 589 | assert.throws(() => semaphore.acquire(-1)); 590 | }); 591 | 592 | test('trying to release with a negative weight throws', () => { 593 | assert.throws(() => semaphore.release(-1)); 594 | }); 595 | 596 | test('trying to waitForUnlock with a negative weight throws', () => { 597 | assert.throws(() => semaphore.waitForUnlock(-1)); 598 | }); 599 | }; 600 | -------------------------------------------------------------------------------- /test/tryAcquire.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import { InstalledClock, install } from '@sinonjs/fake-timers'; 4 | 5 | import { E_ALREADY_LOCKED } from '../src/errors'; 6 | import Mutex from '../src/Mutex'; 7 | import Semaphore from '../src/Semaphore'; 8 | import { tryAcquire } from '../src/tryAcquire'; 9 | 10 | suite('tryAcquire', () => { 11 | suite('Mutex', () => { 12 | let clock: InstalledClock; 13 | 14 | setup(() => { 15 | clock = install(); 16 | }); 17 | 18 | teardown(() => clock.uninstall()); 19 | 20 | test('acquire rejects with error if mutex is already locked', async () => { 21 | const error = new Error(); 22 | const mutex = tryAcquire(new Mutex(), error); 23 | 24 | await mutex.acquire(); 25 | 26 | const ticket = mutex.acquire(); 27 | ticket.then(undefined, undefined); 28 | 29 | await clock.tickAsync(0); 30 | 31 | await assert.rejects(ticket, error); 32 | }); 33 | 34 | test('acquire rejects with E_ALREADY_LOCKER if no error is provided', async () => { 35 | const mutex = tryAcquire(new Mutex()); 36 | await mutex.acquire(); 37 | 38 | const ticket = mutex.acquire(); 39 | ticket.then(undefined, undefined); 40 | 41 | await clock.tickAsync(0); 42 | 43 | await assert.rejects(ticket, E_ALREADY_LOCKED); 44 | }); 45 | 46 | test('acquire locks the mutex if it is not already locked', async () => { 47 | const mutex = tryAcquire(new Mutex()); 48 | 49 | await mutex.acquire(); 50 | 51 | assert(mutex.isLocked()); 52 | }); 53 | }); 54 | 55 | suite('Semaphore', () => { 56 | let clock: InstalledClock; 57 | 58 | setup(() => { 59 | clock = install(); 60 | }); 61 | 62 | teardown(() => clock.uninstall()); 63 | 64 | test('acquire rejects with error if semaphore is already locked', async () => { 65 | const error = new Error(); 66 | const semaphore = tryAcquire(new Semaphore(2), error); 67 | 68 | await semaphore.acquire(); 69 | await semaphore.acquire(); 70 | 71 | const ticket = semaphore.acquire(); 72 | ticket.then(undefined, undefined); 73 | 74 | await clock.tickAsync(0); 75 | 76 | await assert.rejects(ticket, error); 77 | }); 78 | 79 | test('acquire rejects with E_ALREADY_LOCKER if no error is provided', async () => { 80 | const semaphore = tryAcquire(new Semaphore(2)); 81 | 82 | await semaphore.acquire(); 83 | await semaphore.acquire(); 84 | 85 | const ticket = semaphore.acquire(); 86 | ticket.then(undefined, undefined); 87 | 88 | await clock.tickAsync(0); 89 | 90 | await assert.rejects(ticket, E_ALREADY_LOCKED); 91 | }); 92 | 93 | test('acquire locks the semaphore if it is not already locked', async () => { 94 | const semaphore = tryAcquire(new Semaphore(2)); 95 | 96 | await semaphore.acquire(); 97 | await semaphore.acquire(); 98 | 99 | assert(semaphore.isLocked()); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | import { Clock } from '@sinonjs/fake-timers'; 2 | 3 | export const withTimer = async (clock: Clock, test: () => Promise): Promise => { 4 | const result = test(); 5 | 6 | await clock.runAllAsync(); 7 | 8 | return result; 9 | }; 10 | -------------------------------------------------------------------------------- /test/withTimeout.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | import { InstalledClock, install } from '@sinonjs/fake-timers'; 4 | 5 | import { E_TIMEOUT } from './../src/errors'; 6 | import Mutex from '../src/Mutex'; 7 | import MutexInterface from '../src/MutexInterface'; 8 | import Semaphore from '../src/Semaphore'; 9 | import SemaphoreInterface from '../src/SemaphoreInterface'; 10 | import { mutexSuite } from './mutex'; 11 | import { semaphoreSuite } from './semaphoreSuite'; 12 | import { withTimeout } from '../src/withTimeout'; 13 | 14 | suite('withTimeout', () => { 15 | suite('Mutex', () => { 16 | suite('timeout behavior', () => { 17 | let clock: InstalledClock; 18 | let mutex: MutexInterface; 19 | const error = new Error(); 20 | 21 | setup(() => { 22 | mutex = withTimeout(new Mutex(), 100, error); 23 | clock = install(); 24 | }); 25 | 26 | teardown(() => clock.uninstall()); 27 | 28 | test('acquire clears timeout if lock is acquired', async () => { 29 | await mutex.acquire().then((release) => release()); 30 | mutex.acquire().then((release) => setTimeout(release, 75)); 31 | 32 | await clock.tickAsync(25); 33 | assert.strictEqual(clock.countTimers(), 1); 34 | }); 35 | 36 | test('acquire rejects with timeout error if timeout is exceeded', async () => { 37 | mutex.acquire().then((release) => setTimeout(release, 150)); 38 | 39 | const ticket = mutex.acquire(); 40 | ticket.then(undefined, () => undefined); 41 | 42 | await clock.tickAsync(110); 43 | await assert.rejects(ticket, error); 44 | }); 45 | 46 | test('after a timeout, acquire does automatically release the mutex once it is acquired', async () => { 47 | mutex.acquire().then((release) => setTimeout(release, 150)); 48 | 49 | mutex.acquire().then(undefined, () => undefined); 50 | 51 | await clock.tickAsync(160); 52 | 53 | let flag = false; 54 | 55 | mutex.acquire().then(() => (flag = true)); 56 | 57 | await clock.tickAsync(0); 58 | 59 | assert.strictEqual(flag, true); 60 | }); 61 | 62 | test('runExclusive rejects with E_TIMEOUT if no error is specified', async () => { 63 | const mutex = withTimeout(new Mutex(), 100); 64 | mutex.acquire().then((release) => setTimeout(release, 150)); 65 | 66 | const result = mutex.runExclusive(() => undefined); 67 | result.then(undefined, () => undefined); 68 | 69 | await clock.tickAsync(110); 70 | 71 | await assert.rejects(result, E_TIMEOUT); 72 | }); 73 | 74 | test('runExclusive does not run the callback if timeout is exceeded', async () => { 75 | mutex.acquire().then((release) => setTimeout(release, 150)); 76 | 77 | let flag = false; 78 | 79 | const result = mutex.runExclusive(() => (flag = true)); 80 | result.then(undefined, () => undefined); 81 | 82 | await clock.tickAsync(160); 83 | 84 | assert.strictEqual(flag, false); 85 | }); 86 | 87 | test('after a timeout, runExclusive automatically releases the mutex once it is acquired', async () => { 88 | mutex.acquire().then((release) => setTimeout(release, 150)); 89 | 90 | const result = mutex.runExclusive(() => undefined); 91 | result.then(undefined, () => undefined); 92 | 93 | await clock.tickAsync(160); 94 | 95 | let flag = false; 96 | 97 | mutex.runExclusive(() => (flag = true)); 98 | 99 | await clock.tickAsync(0); 100 | 101 | assert.strictEqual(flag, true); 102 | }); 103 | 104 | test('a canceled lock with timeout will not lock the mutex again', async () => { 105 | mutex.acquire().then((release) => setTimeout(release, 150)); 106 | 107 | const ticket = mutex.acquire(); 108 | ticket.then(undefined, () => undefined); 109 | 110 | await clock.tickAsync(120); 111 | mutex.cancel(); 112 | 113 | assert(mutex.isLocked()); 114 | 115 | await clock.tickAsync(50); 116 | 117 | assert(!mutex.isLocked()); 118 | }); 119 | 120 | test('waitForUnlock times out', async () => { 121 | mutex.acquire(); 122 | let state = 'PENDING'; 123 | 124 | mutex.waitForUnlock() 125 | .then(() => { state = 'RESOLVED'; }) 126 | .catch(() => { state = 'REJECTED'; }); 127 | await clock.tickAsync(120); 128 | 129 | assert.strictEqual(state, 'REJECTED'); 130 | }); 131 | }); 132 | 133 | suite('Mutex API', () => mutexSuite((e) => withTimeout(new Mutex(e), 500))); 134 | }); 135 | 136 | suite('Semaphore', () => { 137 | suite('timeout behavior', () => { 138 | let clock: InstalledClock; 139 | let semaphore: SemaphoreInterface; 140 | const error = new Error(); 141 | 142 | setup(() => { 143 | semaphore = withTimeout(new Semaphore(2), 100, error); 144 | clock = install(); 145 | }); 146 | 147 | teardown(() => clock.uninstall()); 148 | 149 | test('acquire clears timeout if lock is acquired', async () => { 150 | await semaphore.acquire().then(([, release]) => release()); 151 | semaphore.acquire().then(([, release]) => setTimeout(release, 75)); 152 | 153 | await clock.tickAsync(25); 154 | assert.strictEqual(clock.countTimers(), 1); 155 | }); 156 | 157 | test('acquire rejects with timeout error if timeout is exceeded', async () => { 158 | semaphore.acquire(); 159 | semaphore.acquire().then(([, release]) => setTimeout(release, 150)); 160 | 161 | const ticket = semaphore.acquire(); 162 | ticket.then(undefined, () => undefined); 163 | 164 | await clock.tickAsync(110); 165 | await assert.rejects(ticket, error); 166 | }); 167 | 168 | test('after a timeout, acquire does automatically release the semaphore once it is acquired', async () => { 169 | semaphore.acquire(); 170 | semaphore.acquire().then(([, release]) => setTimeout(release, 150)); 171 | 172 | semaphore.acquire().then(undefined, () => undefined); 173 | 174 | await clock.tickAsync(160); 175 | 176 | let flag = false; 177 | 178 | semaphore.acquire().then(() => (flag = true)); 179 | 180 | await clock.tickAsync(0); 181 | 182 | assert.strictEqual(flag, true); 183 | }); 184 | 185 | test('runExclusive rejects with timeout error if timeout is exceeded', async () => { 186 | semaphore.acquire(); 187 | semaphore.acquire().then(([, release]) => setTimeout(release, 150)); 188 | 189 | const result = semaphore.runExclusive(() => undefined); 190 | result.then(undefined, () => undefined); 191 | 192 | await clock.tickAsync(110); 193 | 194 | await assert.rejects(result, error); 195 | }); 196 | 197 | test('runExclusive rejects with E_TIMEOUT if no error is specified', async () => { 198 | const semaphore = withTimeout(new Semaphore(2), 0); 199 | 200 | semaphore.acquire(); 201 | semaphore.acquire().then(([, release]) => setTimeout(release, 150)); 202 | 203 | const result = semaphore.runExclusive(() => undefined); 204 | result.then(undefined, () => undefined); 205 | 206 | await clock.tickAsync(110); 207 | 208 | await assert.rejects(result, E_TIMEOUT); 209 | }); 210 | 211 | test('runExclusive does not run the callback if timeout is exceeded', async () => { 212 | semaphore.acquire(); 213 | semaphore.acquire().then(([, release]) => setTimeout(release, 150)); 214 | 215 | let flag = false; 216 | 217 | const result = semaphore.runExclusive(() => (flag = true)); 218 | result.then(undefined, () => undefined); 219 | 220 | await clock.tickAsync(160); 221 | 222 | assert.strictEqual(flag, false); 223 | }); 224 | 225 | test('after a timeout, runExclusive automatically releases the semamphore once it is acquired', async () => { 226 | semaphore.acquire().then(([, release]) => setTimeout(release, 150)); 227 | 228 | const result = semaphore.runExclusive(() => undefined); 229 | result.then(undefined, () => undefined); 230 | 231 | await clock.tickAsync(160); 232 | 233 | let flag = false; 234 | 235 | semaphore.runExclusive(() => (flag = true)); 236 | 237 | await clock.tickAsync(0); 238 | 239 | assert.strictEqual(flag, true); 240 | }); 241 | 242 | test('a canceled lock with timeout will not lock the semaphore again', async () => { 243 | semaphore.acquire().then(([, release]) => setTimeout(release, 150)); 244 | semaphore.acquire().then(([, release]) => setTimeout(release, 200)); 245 | 246 | const ticket = semaphore.acquire(); 247 | ticket.then(undefined, () => undefined); 248 | 249 | await clock.tickAsync(120); 250 | semaphore.cancel(); 251 | 252 | assert(semaphore.isLocked()); 253 | 254 | await clock.tickAsync(50); 255 | 256 | assert(!semaphore.isLocked()); 257 | }); 258 | 259 | test('waitForUnlock times out', async () => { 260 | semaphore.acquire(2); 261 | let state = 'PENDING'; 262 | 263 | semaphore.waitForUnlock() 264 | .then(() => { state = 'RESOLVED'; }) 265 | .catch(() => { state = 'REJECTED'; }); 266 | 267 | await clock.tickAsync(120); 268 | assert.strictEqual(state, 'REJECTED'); 269 | }); 270 | }); 271 | 272 | suite('Semaphore API', () => 273 | semaphoreSuite((maxConcurrency: number, err?: Error) => 274 | withTimeout(new Semaphore(maxConcurrency, err), 500) 275 | ) 276 | ); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /tsconfig.es6.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "es6", 5 | "outDir": "es6", 6 | "declaration": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": ["es6"], 6 | "moduleResolution": "node", 7 | "sourceMap": false, 8 | "strict": true, 9 | "declaration": true, 10 | "outDir": "lib", 11 | "importHelpers": true, 12 | "noImplicitThis": true, 13 | "noImplicitAny": true, 14 | "noImplicitReturns": true, 15 | "noUnusedLocals": true, 16 | "allowSyntheticDefaultImports": true 17 | }, 18 | "include": ["src/**/*.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.mjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.es6.json", 3 | "compilerOptions": { 4 | "target": "es6", 5 | "outDir": "mjs", 6 | "importHelpers": false, 7 | } 8 | } 9 | --------------------------------------------------------------------------------