Skip to content

Commit b6baa32

Browse files
authored
feat: add TaskSemaphore utility (#675)
* feat(TaskSemaphore): light weight mutex wrapper * test(TaskSemaphore): increasing coverage
1 parent 37008b7 commit b6baa32

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

src/utils/TaskSemaphore.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Semaphore } from "async-mutex"
2+
3+
/**
4+
* A thin wrapper around `async-mutex`'s `Semaphore` that adds observable
5+
* queue-depth (`waiting`) and safe bulk-cancellation (`cancel()`).
6+
*
7+
* **Why not use `Semaphore` directly?**
8+
* `Semaphore` has no way to inspect how many callers are blocked waiting for a
9+
* permit. `TaskSemaphore` tracks that count so callers can make scheduling
10+
* decisions (e.g. "don't enqueue more work when the queue is already deep").
11+
*
12+
* **`_waiting`** is incremented before `sem.acquire()` is awaited (only when
13+
* the semaphore is already locked, i.e. the caller will actually block) and
14+
* decremented once the permit is granted or the acquire is rejected.
15+
*
16+
* **`_generation`** is a monotonically-increasing counter bumped on every
17+
* `cancel()` call. Each in-flight `acquire()` captures the generation at
18+
* enqueue time; when the acquire settles it only adjusts `_waiting` if the
19+
* generation hasn't changed, preventing stale decrements after a cancel has
20+
* already reset the counter to 0.
21+
*/
22+
export class TaskSemaphore {
23+
private sem: Semaphore
24+
private _waiting = 0
25+
private _generation = 0
26+
27+
constructor(permits: number) {
28+
this.sem = new Semaphore(permits)
29+
}
30+
31+
get available(): number {
32+
return this.sem.getValue()
33+
}
34+
35+
get waiting(): number {
36+
return this._waiting
37+
}
38+
39+
async acquire(): Promise<() => void> {
40+
// Only count as waiting if the permit won't be granted immediately.
41+
const willQueue = this.sem.isLocked()
42+
const gen = this._generation
43+
if (willQueue) this._waiting++
44+
try {
45+
const [, release] = await this.sem.acquire()
46+
if (willQueue && gen === this._generation) this._waiting--
47+
return release
48+
} catch (e) {
49+
if (willQueue && gen === this._generation) this._waiting--
50+
throw e
51+
}
52+
}
53+
54+
/**
55+
* Rejects all queued waiters and resets the waiting count to 0.
56+
* Does NOT release or alter any held permits — callers that already
57+
* received a release function must still call it.
58+
* The semaphore remains usable after cancellation.
59+
*/
60+
cancel(): void {
61+
this._waiting = 0
62+
this._generation++
63+
this.sem.cancel()
64+
}
65+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { TaskSemaphore } from "../TaskSemaphore"
2+
3+
describe("TaskSemaphore", () => {
4+
it("acquire() resolves immediately when permits are available", async () => {
5+
const sem = new TaskSemaphore(2)
6+
const release = await sem.acquire()
7+
expect(sem.available).toBe(1)
8+
expect(sem.waiting).toBe(0)
9+
release()
10+
})
11+
12+
it("second acquire() queues when no permits remain; resolves after release", async () => {
13+
const sem = new TaskSemaphore(1)
14+
const release1 = await sem.acquire()
15+
expect(sem.available).toBe(0)
16+
17+
let acquired = false
18+
const p = sem.acquire().then((r) => {
19+
acquired = true
20+
return r
21+
})
22+
23+
await Promise.resolve()
24+
expect(sem.waiting).toBe(1)
25+
expect(acquired).toBe(false)
26+
27+
release1()
28+
const release2 = await p
29+
expect(acquired).toBe(true)
30+
expect(sem.waiting).toBe(0)
31+
release2()
32+
})
33+
34+
it("release restores exactly one permit and unblocks one waiter", async () => {
35+
const sem = new TaskSemaphore(1)
36+
const release1 = await sem.acquire()
37+
38+
const results: number[] = []
39+
const p1 = sem.acquire().then((r) => {
40+
results.push(1)
41+
return r
42+
})
43+
const p2 = sem.acquire().then((r) => {
44+
results.push(2)
45+
return r
46+
})
47+
48+
await Promise.resolve()
49+
expect(sem.waiting).toBe(2)
50+
51+
release1()
52+
const r1 = await p1
53+
expect(results).toEqual([1])
54+
expect(sem.waiting).toBe(1)
55+
56+
r1()
57+
const r2 = await p2
58+
expect(results).toEqual([1, 2])
59+
expect(sem.waiting).toBe(0)
60+
r2()
61+
})
62+
63+
it("available and waiting return correct values at each step", async () => {
64+
const sem = new TaskSemaphore(2)
65+
expect(sem.available).toBe(2)
66+
expect(sem.waiting).toBe(0)
67+
68+
const r1 = await sem.acquire()
69+
expect(sem.available).toBe(1)
70+
expect(sem.waiting).toBe(0)
71+
72+
const r2 = await sem.acquire()
73+
expect(sem.available).toBe(0)
74+
expect(sem.waiting).toBe(0)
75+
76+
const p = sem.acquire()
77+
await Promise.resolve()
78+
expect(sem.waiting).toBe(1)
79+
80+
r1()
81+
await p.then((r) => r())
82+
expect(sem.available).toBe(1)
83+
expect(sem.waiting).toBe(0)
84+
85+
r2()
86+
expect(sem.available).toBe(2)
87+
})
88+
89+
it("cancel() rejects all queued waiters", async () => {
90+
const sem = new TaskSemaphore(1)
91+
const release = await sem.acquire()
92+
93+
const errors: unknown[] = []
94+
const p1 = sem.acquire().catch((e) => errors.push(e))
95+
const p2 = sem.acquire().catch((e) => errors.push(e))
96+
97+
await Promise.resolve()
98+
expect(sem.waiting).toBe(2)
99+
100+
sem.cancel()
101+
await Promise.all([p1, p2])
102+
103+
expect(errors).toHaveLength(2)
104+
release()
105+
})
106+
107+
it("waiting is 0 while an immediate acquire is in flight (permit available)", async () => {
108+
const sem = new TaskSemaphore(2)
109+
// Do NOT await — capture the promise before it settles.
110+
const p = sem.acquire()
111+
// Permit was available so nothing should be queued.
112+
expect(sem.waiting).toBe(0)
113+
const release = await p
114+
expect(sem.waiting).toBe(0)
115+
release()
116+
})
117+
118+
it("cancel() resets waiting count to 0 synchronously", async () => {
119+
const sem = new TaskSemaphore(1)
120+
const release = await sem.acquire()
121+
122+
const p1 = sem.acquire().catch(() => {})
123+
const p2 = sem.acquire().catch(() => {})
124+
125+
await Promise.resolve()
126+
expect(sem.waiting).toBe(2)
127+
128+
sem.cancel()
129+
// Synchronous check — waiting must be 0 before any promise callbacks run.
130+
expect(sem.waiting).toBe(0)
131+
await Promise.all([p1, p2])
132+
133+
expect(sem.waiting).toBe(0)
134+
release()
135+
})
136+
137+
it("acquire() works after cancel() with permits still available", async () => {
138+
const sem = new TaskSemaphore(1)
139+
sem.cancel() // no waiters, no holders
140+
const release = await sem.acquire()
141+
expect(sem.available).toBe(0)
142+
release()
143+
expect(sem.available).toBe(1)
144+
})
145+
146+
it("cancel() on an idle semaphore is a safe no-op", () => {
147+
const sem = new TaskSemaphore(2)
148+
expect(() => sem.cancel()).not.toThrow()
149+
expect(sem.waiting).toBe(0)
150+
expect(sem.available).toBe(2)
151+
})
152+
})

0 commit comments

Comments
 (0)