├── .gitignore ├── LICENSE ├── bench └── bench.js ├── build.ts ├── deno.json ├── dist ├── index.cjs ├── index.d.ts ├── index.js └── index.min.js ├── index.ts ├── package.json ├── readme.md └── test ├── bun.test.ts └── deno.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea.md 3 | *.gz 4 | *.bak 5 | jsr.json 6 | deno.lock 7 | .vscode 8 | bun.lockb 9 | playground.ts -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 henrygd 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bench/bench.js: -------------------------------------------------------------------------------- 1 | import { run, bench, baseline } from 'mitata' 2 | import { getSemaphore } from '../dist/index.min.js' 3 | import { Semaphore as AsyncSemaphore } from 'async-mutex' 4 | import { Semaphore as asSemaphore } from 'await-semaphore' 5 | import { Sema } from 'async-sema' 6 | import { Semaphore } from '@shopify/semaphore' 7 | 8 | const loops = 1_000 9 | const concurrency = 1 10 | 11 | const semaphore = getSemaphore(concurrency) 12 | const asyncSemaphore = new AsyncSemaphore(concurrency) 13 | const shopifySemaphore = new Semaphore(concurrency) 14 | const asSem = new asSemaphore(concurrency) 15 | const s = new Sema(concurrency, { 16 | capacity: loops, // Prealloc space for [loops] tokens 17 | }) 18 | 19 | function checkEqual(a, b) { 20 | if (a !== b) { 21 | throw new Error(`${a} !== ${b}`) 22 | } 23 | } 24 | 25 | baseline('@henrygd/semaphore', async () => { 26 | let j = 0 27 | const { promise, resolve } = Promise.withResolvers() 28 | for (let i = 0; i < loops; i++) { 29 | semaphore.acquire().then(() => { 30 | ++j === loops && resolve() 31 | semaphore.release() 32 | }) 33 | } 34 | await promise 35 | checkEqual(j, loops) 36 | }) 37 | 38 | bench('vercel/async-sema', async () => { 39 | let j = 0 40 | const { promise, resolve } = Promise.withResolvers() 41 | for (let i = 0; i < loops; i++) { 42 | s.acquire().then(() => { 43 | ++j === loops && resolve() 44 | s.release() 45 | }) 46 | } 47 | await promise 48 | checkEqual(j, loops) 49 | }) 50 | 51 | bench('async-mutex', async () => { 52 | let j = 0 53 | const { promise, resolve } = Promise.withResolvers() 54 | for (let i = 0; i < loops; i++) { 55 | asyncSemaphore.acquire().then(() => { 56 | ++j === loops && resolve() 57 | asyncSemaphore.release() 58 | }) 59 | } 60 | await promise 61 | checkEqual(j, loops) 62 | }) 63 | 64 | bench('await-semaphore', async () => { 65 | let j = 0 66 | const { promise, resolve } = Promise.withResolvers() 67 | for (let i = 0; i < loops; i++) { 68 | asSem.acquire().then((release) => { 69 | ++j === loops && resolve() 70 | release() 71 | }) 72 | } 73 | await promise 74 | checkEqual(j, loops) 75 | }) 76 | 77 | bench('@shopify/semaphore', async () => { 78 | let j = 0 79 | const { promise, resolve } = Promise.withResolvers() 80 | for (let i = 0; i < loops; i++) { 81 | shopifySemaphore.acquire().then((permit) => { 82 | ++j === loops && resolve() 83 | permit.release() 84 | }) 85 | } 86 | await promise 87 | checkEqual(j, loops) 88 | }) 89 | 90 | await run() 91 | await run() 92 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild' 2 | 3 | await build({ 4 | entryPoints: ['index.ts'], 5 | mangleProps: /^res$|^rej$|^next$/, 6 | format: 'esm', 7 | outfile: './dist/index.js', 8 | }) 9 | 10 | await build({ 11 | entryPoints: ['index.ts'], 12 | minify: true, 13 | mangleProps: /^res$|^rej$|^next$/, 14 | format: 'esm', 15 | outfile: './dist/index.min.js', 16 | }) 17 | 18 | await build({ 19 | entryPoints: ['index.ts'], 20 | mangleProps: /^res$|^rej$|^next$/, 21 | format: 'cjs', 22 | outfile: './dist/index.cjs', 23 | }) 24 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@henrygd/semaphore", 3 | "version": "0.0.2", 4 | "exports": "./index.ts", 5 | "lint": { 6 | "exclude": ["*"], 7 | "include": ["test/deno.test.ts"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /dist/index.cjs: -------------------------------------------------------------------------------- 1 | var __defProp = Object.defineProperty; 2 | var __getOwnPropDesc = Object.getOwnPropertyDescriptor; 3 | var __getOwnPropNames = Object.getOwnPropertyNames; 4 | var __hasOwnProp = Object.prototype.hasOwnProperty; 5 | var __export = (target, all) => { 6 | for (var name in all) 7 | __defProp(target, name, { get: all[name], enumerable: true }); 8 | }; 9 | var __copyProps = (to, from, except, desc) => { 10 | if (from && typeof from === "object" || typeof from === "function") { 11 | for (let key of __getOwnPropNames(from)) 12 | if (!__hasOwnProp.call(to, key) && key !== except) 13 | __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); 14 | } 15 | return to; 16 | }; 17 | var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); 18 | var semaphore_exports = {}; 19 | __export(semaphore_exports, { 20 | getSemaphore: () => getSemaphore 21 | }); 22 | module.exports = __toCommonJS(semaphore_exports); 23 | let semaphoreMap = /* @__PURE__ */ new Map(); 24 | let getSemaphore = (key = Symbol(), concurrency = 1) => { 25 | if (semaphoreMap.has(key)) { 26 | return semaphoreMap.get(key); 27 | } 28 | let size = 0; 29 | let head; 30 | let tail; 31 | let createPromise = (res) => { 32 | if (head) { 33 | tail = tail.b = { a: res }; 34 | } else { 35 | tail = head = { a: res }; 36 | } 37 | }; 38 | let semaphore = { 39 | acquire: () => { 40 | if (++size <= concurrency) { 41 | return Promise.resolve(); 42 | } 43 | return new Promise(createPromise); 44 | }, 45 | release() { 46 | head?.a(); 47 | head = head?.b; 48 | if (size && !--size) { 49 | semaphoreMap.delete(key); 50 | } 51 | }, 52 | size: () => size 53 | }; 54 | semaphoreMap.set(key, semaphore); 55 | return semaphore; 56 | }; 57 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | /** Semaphore interface */ 2 | interface Semaphore { 3 | /** Returns a promise that resolves when access is acquired */ 4 | acquire(): Promise; 5 | /** Release access to the semaphore */ 6 | release(): void; 7 | /** Returns the total number of tasks active or waiting for access */ 8 | size(): number; 9 | } 10 | /** 11 | * Creates or retrieves existing semaphore with optional key and concurrency level. 12 | * 13 | * @param {any} [key=Symbol()] - Key used to identify the semaphore. Defaults to `Symbol()`. 14 | * @param {number} [concurrency=1] - Maximum concurrent tasks allowed access. Defaults to `1`. 15 | */ 16 | export declare let getSemaphore: (key?: any, concurrency?: number) => Semaphore; 17 | export {}; 18 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | let semaphoreMap = /* @__PURE__ */ new Map(); 2 | let getSemaphore = (key = Symbol(), concurrency = 1) => { 3 | if (semaphoreMap.has(key)) { 4 | return semaphoreMap.get(key); 5 | } 6 | let size = 0; 7 | let head; 8 | let tail; 9 | let createPromise = (res) => { 10 | if (head) { 11 | tail = tail.b = { a: res }; 12 | } else { 13 | tail = head = { a: res }; 14 | } 15 | }; 16 | let semaphore = { 17 | acquire: () => { 18 | if (++size <= concurrency) { 19 | return Promise.resolve(); 20 | } 21 | return new Promise(createPromise); 22 | }, 23 | release() { 24 | head?.a(); 25 | head = head?.b; 26 | if (size && !--size) { 27 | semaphoreMap.delete(key); 28 | } 29 | }, 30 | size: () => size 31 | }; 32 | semaphoreMap.set(key, semaphore); 33 | return semaphore; 34 | }; 35 | export { 36 | getSemaphore 37 | }; 38 | -------------------------------------------------------------------------------- /dist/index.min.js: -------------------------------------------------------------------------------- 1 | let r=new Map,m=(i=Symbol(),s=1)=>{if(r.has(i))return r.get(i);let o=0,e,a,l=d=>{e?a=a.i={e:d}:a=e={e:d}},t={acquire:()=>++o<=s?Promise.resolve():new Promise(l),release(){e?.e(),e=e?.i,o&&!--o&&r.delete(i)},size:()=>o};return r.set(i,t),t};export{m as getSemaphore}; 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /** List node */ 2 | type Node = { 3 | /** resolve promise */ 4 | res: (value: void | PromiseLike) => void 5 | /** next node pointer */ 6 | next?: Node 7 | } 8 | 9 | /** Semaphore interface */ 10 | interface Semaphore { 11 | /** Returns a promise that resolves when access is acquired */ 12 | acquire(): Promise 13 | /** Release access to the semaphore */ 14 | release(): void 15 | /** Returns the total number of tasks active or waiting for access */ 16 | size(): number 17 | } 18 | 19 | /** Holds active semaphores by key */ 20 | let semaphoreMap = new Map() as Map 21 | 22 | /** 23 | * Creates or retrieves existing semaphore with optional key and concurrency level. 24 | * 25 | * @param {any} [key=Symbol()] - Key used to identify the semaphore. Defaults to `Symbol()`. 26 | * @param {number} [concurrency=1] - Maximum concurrent tasks allowed access. Defaults to `1`. 27 | */ 28 | export let getSemaphore = (key: any = Symbol(), concurrency = 1): Semaphore => { 29 | // return saved semaphore if exists 30 | if (semaphoreMap.has(key)) { 31 | return semaphoreMap.get(key) as Semaphore 32 | } 33 | 34 | let size = 0 35 | let head: Node | undefined 36 | let tail: Node | undefined 37 | 38 | let createPromise = (res: (value: void | PromiseLike) => void) => { 39 | if (head) { 40 | tail = tail!.next = { res } 41 | } else { 42 | tail = head = { res } 43 | } 44 | } 45 | 46 | let semaphore = { 47 | acquire: () => { 48 | if (++size <= concurrency) { 49 | return Promise.resolve() 50 | } 51 | return new Promise(createPromise) 52 | }, 53 | release() { 54 | head?.res() 55 | head = head?.next 56 | // make sure size is not negative 57 | if (size && !--size) { 58 | semaphoreMap.delete(key) 59 | } 60 | }, 61 | size: () => size, 62 | } 63 | 64 | semaphoreMap.set(key, semaphore) 65 | return semaphore 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@henrygd/semaphore", 3 | "version": "0.0.2", 4 | "license": "MIT", 5 | "type": "module", 6 | "description": "Fast inline semaphores and mutexes", 7 | "author": "Hank Dollman (https://henrygd.me)", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/henrygd/semaphore.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/henrygd/semaphore/issues" 14 | }, 15 | "exports": { 16 | ".": { 17 | "import": "./dist/index.js", 18 | "require": "./dist/index.cjs", 19 | "types": "./dist/index.d.ts" 20 | } 21 | }, 22 | "types": "./dist/index.d.ts", 23 | "scripts": { 24 | "build": "bun run build.ts && ls -l dist/index.min.js && bun run generate-types", 25 | "build:skip-types": "bun run build.ts && ls -l dist/index.min.js", 26 | "generate-types": "tsc -d index.ts --outDir dist --emitDeclarationOnly > /dev/null", 27 | "test": "bun run test:dev && bun test:dist", 28 | "test:dev": "bun test test/bun.test.ts", 29 | "test:dist": "DIST=true bun test test/bun.test.ts", 30 | "test:deno": "bun run test:deno:dev && bun run test:deno:dist", 31 | "test:deno:dist": "DIST=true deno test test/deno.test.ts --allow-env", 32 | "test:deno:dev": "deno test test/deno.test.ts --allow-env", 33 | "bench": "node bench/bench.js", 34 | "bench:bun": "bun run bench/bench.js", 35 | "bench:deno": "deno run --allow-env --allow-hrtime --allow-sys bench/bench.js" 36 | }, 37 | "keywords": [ 38 | "mutex", 39 | "semaphore", 40 | "lock", 41 | "async", 42 | "await", 43 | "concurrency", 44 | "promise" 45 | ], 46 | "devDependencies": { 47 | "@shopify/semaphore": "^3.1.0", 48 | "@types/bun": "^1.1.5", 49 | "async-mutex": "^0.5.0", 50 | "async-sema": "^3.1.1", 51 | "await-semaphore": "^0.1.3", 52 | "esbuild": "^0.21.5", 53 | "mitata": "^0.1.11", 54 | "typescript": "^5.5.2" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [size-image]: https://img.shields.io/github/size/henrygd/semaphore/dist/index.min.js?style=flat 2 | [license-image]: https://img.shields.io/github/license/henrygd/semaphore?style=flat&color=%2349ac0c 3 | [license-url]: /LICENSE 4 | 5 | # @henrygd/semaphore 6 | 7 | [![File Size][size-image]](https://github.com/henrygd/semaphore/blob/main/dist/index.min.js) [![MIT license][license-image]][license-url] [![JSR Score 100%](https://jsr.io/badges/@henrygd/semaphore/score?v1)](https://jsr.io/@henrygd/semaphore) 8 | 9 | Fast inline semaphores and mutexes. See [comparisons and benchmarks](#comparisons-and-benchmarks). 10 | 11 | Semaphores limit simultaneous access to code and resources (e.g. a file) among multiple concurrent tasks. 12 | 13 | Works with: browsers Deno Node.js Cloudflare Workers Bun 14 | 15 | ## Usage 16 | 17 | Create or retrieve a semaphore by calling `getSemaphore` with optional key and concurrency limit. 18 | 19 | ```js 20 | const sem = getSemaphore('key', 1) 21 | ``` 22 | 23 | Use the `acquire` and `release` methods to limit access. 24 | 25 | ```js 26 | await sem.acquire() 27 | // access here is limited to one task at a time 28 | sem.release() 29 | ``` 30 | 31 | ## Full example 32 | 33 | We use semaphores here to prevent multiple requests to an API for the same resource. 34 | 35 | The first calls to `fetchPokemon` will acquire access to the protected code. Subsequent calls will wait, then return the data from the cache. 36 | 37 | We use a key to allow access based on the name. This lets `ditto` and `snorlax` run simultaneously. 38 | 39 | 40 | ```js 41 | import { getSemaphore } from '@henrygd/semaphore' 42 | 43 | const cache = new Map() 44 | 45 | for (let i = 0; i < 5; i++) { 46 | fetchPokemon('ditto') 47 | fetchPokemon('snorlax') 48 | } 49 | 50 | async function fetchPokemon(name) { 51 | // get semaphore with key based on name 52 | const sem = getSemaphore(name) 53 | // acquire access from the semaphore 54 | await sem.acquire() 55 | try { 56 | // return data from cache if available 57 | if (cache.has(name)) { 58 | console.log('Cache hit:', name) 59 | return cache.get(name) 60 | } 61 | // otherwise fetch from API 62 | console.warn('Fetching from API:', name) 63 | const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`) 64 | const json = await res.json() 65 | cache.set(name, json) 66 | return json 67 | } finally { 68 | // release access when done 69 | sem.release() 70 | } 71 | } 72 | ``` 73 | 74 | ## Interface 75 | 76 | 77 | ```ts 78 | /** 79 | * Creates or retrieves existing semaphore with optional key and concurrency level. 80 | * 81 | * key - Key used to identify the semaphore. Defaults to `Symbol()`. 82 | * concurrency - Maximum concurrent tasks allowed access. Defaults to `1`. 83 | */ 84 | function getSemaphore(key?: any, concurrency?: number): Semaphore 85 | 86 | interface Semaphore { 87 | /** Returns a promise that resolves when access is acquired */ 88 | acquire(): Promise 89 | /** Release access to the semaphore */ 90 | release(): void 91 | /** Returns the total number of tasks active or waiting for access */ 92 | size(): number 93 | } 94 | ``` 95 | 96 | ### Keys and persistence 97 | 98 | Keyed semaphores are held in a `Map` and deleted from the `Map` once they've been acquired and fully released (no waiting tasks). 99 | 100 | If you need to reuse the same semaphore even after deletion from the `Map`, use a persistent variable instead of calling `getSemaphore` again. 101 | 102 | ### Concurrency 103 | 104 | Concurrency is set for each semaphore on first creation via `getSemaphore`. If called again using the key for an active semaphore, the concurrency argument is ignored and the existing semaphore is returned. 105 | 106 | ## Comparisons and benchmarks 107 | 108 | Note that we're looking at libraries which provide a promise-based locking mechanism, not callbacks. 109 | 110 | | Library | Version | Bundle size (B) | Keys | Weekly Downloads | 111 | | :--------------------------------------------------------------------- | :------ | :-------------- | :--- | :--------------- | 112 | | @henrygd/semaphore | 0.0.1 | 267 | yes | ¯\\\_(ツ)\_/¯ | 113 | | [async-mutex](https://www.npmjs.com/package/async-mutex) | 0.5.0 | 4,758 | no | 1,639,071 | 114 | | [async-sema](https://www.npmjs.com/package/async-sema) | 3.1.1 | 3,532 | no | 1,258,877 | 115 | | [await-semaphore](https://www.npmjs.com/package/await-semaphore) | 0.1.3 | 1,184 | no | 60,449 | 116 | | [@shopify/semaphore](https://www.npmjs.com/package/@shopify/semaphore) | 3.1.0 | 604 | no | 29,089 | 117 | 118 | > If there's a library you'd like added to the table or benchmarks, please open an issue. 119 | 120 | ## Benchmarks 121 | 122 | All libraries run the same test. Each operation measures how long it takes a binary semaphore with 1,000 queued `acquire` requests to allow and release all requests. 123 | 124 | ### Browser benchmark 125 | 126 | This test was run in Chromium. Chrome and Edge are the same. Safari is more lopsided with Vercel's `async-sema` dropping to third. Firefox, though I love and respect it, seems to be hard capped by slow promise handling, with `async-mutex` not far behind. 127 | 128 | You can run or tweak for yourself here: https://jsbm.dev/8bBxR1pBLw0TM 129 | 130 | ![@henrygd/queue - 13,665 Ops/s. async-sema - 8,077 Ops/s. async-mutex - 5,576 Ops/s. @shopify/semaphore - 4,099 Ops/s.](https://henrygd-assets.b-cdn.net/semaphore/browser.png) 131 | 132 | > Note: `await-semaphore` is extremely slow for some reason and I didn't want to include it in the image because it seems excessive. Not sure what's happening there. 133 | 134 | ### Node.js benchmark 135 | 136 | ![@henrygd/queue - 1.7x faster than async-sema. 2.66x async-mutex. 3.08x async-semaphore. 3.47x @shopify/semaphore.](https://henrygd-assets.b-cdn.net/semaphore/node.png) 137 | 138 | ### Bun benchmark 139 | 140 | ![@henrygd/queue - 2x faster than async-semaphore 2.63x asynsc-mutex. 2.68x async-sema. 3.77x @shopify/semaphore.](https://henrygd-assets.b-cdn.net/semaphore/bun-bench.png) 141 | 142 | ### Deno benchmark 143 | 144 | ![@henrygd/queue - 1.7x faster than async-sema. 2.7x async-mutex. 2.72x await-semaphore. 4.01x @shopify/semaphore.](https://henrygd-assets.b-cdn.net/semaphore/deno-bench.png) 145 | 146 | ### Cloudflare Workers benchmark 147 | 148 | Uses [oha](https://github.com/hatoo/oha) to make 1,000 requests to each worker. Each request creates a semaphore and resolves 5,000 acquisitions / releases. 149 | 150 | This was run locally using [Wrangler](https://developers.cloudflare.com/workers/get-started/guide/). Wrangler uses the same [workerd](https://github.com/cloudflare/workerd) runtime as workers deployed to Cloudflare, so the relative difference should be accurate. Here's the [repo for this benchmark](https://github.com/henrygd/semaphore-wrangler-benchmark). 151 | 152 | | Library | Requests/sec | Total (sec) | Average | Slowest | 153 | | :----------------- | :----------- | :---------- | :------ | :------ | 154 | | @henrygd/semaphore | 941.8135 | 1.0618 | 0.0521 | 0.0788 | 155 | | async-mutex | 569.5130 | 1.7559 | 0.0862 | 0.1251 | 156 | | async-sema | 375.7332 | 2.6615 | 0.1308 | 0.1818 | 157 | | @shopify/semaphore | 167.8239 | 5.9586 | 0.2925 | 0.4063 | 158 | | await-semaphore\* | n/a | n/a | n/a | n/a | 159 | 160 | > \* `await-semaphore` does not work with concurrent requests. 161 | 162 | ## Related 163 | 164 | [`@henrygd/queue`](https://github.com/henrygd/queue) - Tiny async queue with concurrency control. Like p-limit or fastq, but smaller and faster. 165 | 166 | ## License 167 | 168 | [MIT license](/LICENSE) 169 | -------------------------------------------------------------------------------- /test/bun.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'bun:test' 2 | import { env } from 'bun' 3 | import { getSemaphore as getSemaphoreDev } from '../index.ts' 4 | import { getSemaphore as getSemaphoreDist } from '../dist/index.js' 5 | 6 | let getSemaphore: typeof getSemaphoreDev 7 | if (env.DIST) { 8 | console.log('using dist files') 9 | getSemaphore = getSemaphoreDist 10 | } else { 11 | console.log('using dev files') 12 | getSemaphore = getSemaphoreDev 13 | } 14 | 15 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 16 | 17 | test('Simple acquire and release', async () => { 18 | const sem = getSemaphore() 19 | expect(sem.size()).toBe(0) 20 | await sem.acquire() 21 | expect(sem.size()).toBe(1) 22 | sem.release() 23 | expect(sem.size()).toBe(0) 24 | await sem.acquire() 25 | expect(sem.size()).toBe(1) 26 | sem.release() 27 | expect(sem.size()).toBe(0) 28 | }) 29 | 30 | test('Releases properly after task', async () => { 31 | const sem = getSemaphore() 32 | expect(sem.size()).toBe(0) 33 | sem 34 | .acquire() 35 | .then(async () => await wait(100)) 36 | .finally(() => sem.release()) 37 | expect(sem.size()).toBe(1) 38 | await wait(50) 39 | expect(sem.size()).toBe(1) 40 | await wait(60) 41 | expect(sem.size()).toBe(0) 42 | }) 43 | 44 | test('Staggered locks', async () => { 45 | const sem = getSemaphore('testing', 2) 46 | let val = 0 47 | 48 | sem 49 | .acquire() 50 | .then(() => wait(100)) 51 | .then(() => (val = 20)) 52 | .finally(sem.release) 53 | 54 | sem 55 | .acquire() 56 | .then(() => wait(50)) 57 | .then(() => (val = 10)) 58 | .finally(sem.release) 59 | 60 | expect(sem.size()).toBe(2) 61 | expect(val).toBe(0) 62 | 63 | await sem 64 | .acquire() 65 | .then(() => expect(val).toBe(10)) 66 | .finally(sem.release) 67 | 68 | expect(sem.size()).toBe(1) 69 | // wait for first lock to complete 70 | await wait(110) 71 | expect(sem.size()).toBe(0) 72 | }) 73 | 74 | test('Can reuse same active sem with key', async () => { 75 | for (let i = 0; i < 5; i++) { 76 | ;(async () => { 77 | const sem = getSemaphore('reuse-test') 78 | await sem.acquire() 79 | await wait(10) 80 | sem.release() 81 | })() 82 | } 83 | await wait(5) 84 | const sem = getSemaphore('reuse-test') 85 | for (let i = 5; i >= 0; i--) { 86 | expect(sem.size()).toBe(i) 87 | await wait(10) 88 | } 89 | }) 90 | 91 | test('Cannot reuse sem w/o key', async () => { 92 | for (let i = 0; i < 5; i++) { 93 | ;(async () => { 94 | const sem = getSemaphore() 95 | await sem.acquire() 96 | await wait(10) 97 | sem.release() 98 | })() 99 | } 100 | await wait(5) 101 | const sem = getSemaphore() 102 | for (let i = 5; i >= 0; i--) { 103 | expect(sem.size()).toBe(0) 104 | await wait(10) 105 | } 106 | }) 107 | 108 | test('Concurrency works', async () => { 109 | const sem = getSemaphore('concurrency-test', 5) 110 | for (let i = 0; i < 50; i++) { 111 | ;(async () => { 112 | await sem.acquire() 113 | await wait(10) 114 | sem.release() 115 | })() 116 | } 117 | await wait(5) 118 | for (let i = 50; i >= 0; i -= 5) { 119 | expect(sem.size()).toBe(i) 120 | await wait(10) 121 | } 122 | }) 123 | 124 | test("Won't explode if release is called too many times", async () => { 125 | const semaphore = getSemaphore('testing', 2) 126 | expect(semaphore.size()).toBe(0) 127 | semaphore.release() 128 | expect(semaphore.size()).toBe(0) 129 | await semaphore.acquire() 130 | expect(semaphore.size()).toBe(1) 131 | semaphore.release() 132 | semaphore.release() 133 | semaphore.release() 134 | expect(semaphore.size()).toBe(0) 135 | await semaphore.acquire() 136 | expect(semaphore.size()).toBe(1) 137 | await semaphore.acquire() 138 | expect(semaphore.size()).toBe(2) 139 | semaphore.release() 140 | semaphore.release() 141 | semaphore.release() 142 | expect(semaphore.size()).toBe(0) 143 | }) 144 | -------------------------------------------------------------------------------- /test/deno.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'jsr:@std/expect' 2 | import { getSemaphore as getMutexDev } from '../index.ts' 3 | import { getSemaphore as getMutexDist } from '../dist/index.js' 4 | 5 | const test = Deno.test 6 | 7 | let getSemaphore: typeof getMutexDev 8 | if (Deno.env.get('DIST')) { 9 | console.log('using dist files') 10 | getSemaphore = getMutexDist 11 | } else { 12 | console.log('using dev files') 13 | getSemaphore = getMutexDev 14 | } 15 | 16 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 17 | 18 | test('Simple acquire and release', async () => { 19 | const sem = getSemaphore() 20 | expect(sem.size()).toBe(0) 21 | await sem.acquire() 22 | expect(sem.size()).toBe(1) 23 | sem.release() 24 | expect(sem.size()).toBe(0) 25 | await sem.acquire() 26 | expect(sem.size()).toBe(1) 27 | sem.release() 28 | expect(sem.size()).toBe(0) 29 | }) 30 | 31 | test('Releases properly after task', async () => { 32 | const sem = getSemaphore() 33 | expect(sem.size()).toBe(0) 34 | sem 35 | .acquire() 36 | .then(async () => await wait(100)) 37 | .finally(() => sem.release()) 38 | expect(sem.size()).toBe(1) 39 | await wait(50) 40 | expect(sem.size()).toBe(1) 41 | await wait(60) 42 | expect(sem.size()).toBe(0) 43 | }) 44 | 45 | test('Staggered locks', async () => { 46 | const sem = getSemaphore('testing', 2) 47 | let val = 0 48 | 49 | sem 50 | .acquire() 51 | .then(() => wait(100)) 52 | .then(() => (val = 20)) 53 | .finally(sem.release) 54 | 55 | sem 56 | .acquire() 57 | .then(() => wait(50)) 58 | .then(() => (val = 10)) 59 | .finally(sem.release) 60 | 61 | expect(sem.size()).toBe(2) 62 | expect(val).toBe(0) 63 | 64 | await sem 65 | .acquire() 66 | .then(() => expect(val).toBe(10)) 67 | .finally(sem.release) 68 | 69 | expect(sem.size()).toBe(1) 70 | // wait for first lock to complete 71 | await wait(110) 72 | expect(sem.size()).toBe(0) 73 | }) 74 | 75 | test('Can reuse same active sem with key', async () => { 76 | for (let i = 0; i < 5; i++) { 77 | ;(async () => { 78 | const sem = getSemaphore('reuse-test') 79 | await sem.acquire() 80 | await wait(10) 81 | sem.release() 82 | })() 83 | } 84 | await wait(5) 85 | const sem = getSemaphore('reuse-test') 86 | for (let i = 5; i >= 0; i--) { 87 | expect(sem.size()).toBe(i) 88 | await wait(10) 89 | } 90 | }) 91 | 92 | test('Cannot reuse sem w/o key', async () => { 93 | for (let i = 0; i < 5; i++) { 94 | ;(async () => { 95 | const sem = getSemaphore() 96 | await sem.acquire() 97 | await wait(10) 98 | sem.release() 99 | })() 100 | } 101 | await wait(5) 102 | const sem = getSemaphore() 103 | for (let i = 5; i >= 0; i--) { 104 | expect(sem.size()).toBe(0) 105 | await wait(10) 106 | } 107 | }) 108 | 109 | test('Concurrency works', async () => { 110 | const sem = getSemaphore('concurrency-test', 5) 111 | for (let i = 0; i < 50; i++) { 112 | ;(async () => { 113 | await sem.acquire() 114 | await wait(10) 115 | sem.release() 116 | })() 117 | } 118 | await wait(5) 119 | for (let i = 50; i >= 0; i -= 5) { 120 | expect(sem.size()).toBe(i) 121 | await wait(10) 122 | } 123 | }) 124 | 125 | test("Won't explode if release is called too many times", async () => { 126 | const semaphore = getSemaphore('testing', 2) 127 | expect(semaphore.size()).toBe(0) 128 | semaphore.release() 129 | expect(semaphore.size()).toBe(0) 130 | await semaphore.acquire() 131 | expect(semaphore.size()).toBe(1) 132 | semaphore.release() 133 | semaphore.release() 134 | semaphore.release() 135 | expect(semaphore.size()).toBe(0) 136 | await semaphore.acquire() 137 | expect(semaphore.size()).toBe(1) 138 | await semaphore.acquire() 139 | expect(semaphore.size()).toBe(2) 140 | semaphore.release() 141 | semaphore.release() 142 | semaphore.release() 143 | expect(semaphore.size()).toBe(0) 144 | }) 145 | --------------------------------------------------------------------------------