@furystack/utils 8.1.10 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { sleepAsync } from './sleep-async.js'
3
+ import { using } from './using.js'
4
+ import { Semaphore, SemaphoreDisposedError } from './semaphore.js'
5
+
6
+ export const semaphoreTests = describe('Semaphore', () => {
7
+ it('should be constructed with a given concurrency limit', () => {
8
+ using(new Semaphore(3), (s) => {
9
+ expect(s).toBeInstanceOf(Semaphore)
10
+ expect(s.getMaxConcurrent()).toBe(3)
11
+ expect(s.pendingCount.getValue()).toBe(0)
12
+ expect(s.runningCount.getValue()).toBe(0)
13
+ expect(s.completedCount.getValue()).toBe(0)
14
+ expect(s.failedCount.getValue()).toBe(0)
15
+ })
16
+ })
17
+
18
+ it('should execute a single task and return its result', async () => {
19
+ const s = new Semaphore(2)
20
+ const result = await s.execute(async () => 42)
21
+ expect(result).toBe(42)
22
+ expect(s.completedCount.getValue()).toBe(1)
23
+ expect(s.runningCount.getValue()).toBe(0)
24
+ s[Symbol.dispose]()
25
+ })
26
+
27
+ it('should execute up to N tasks concurrently and queue the rest', async () => {
28
+ const s = new Semaphore(2)
29
+ const running: string[] = []
30
+ const resolvers: Array<() => void> = []
31
+
32
+ const createTask = (name: string) =>
33
+ s.execute(async () => {
34
+ running.push(name)
35
+ await new Promise<void>((resolve) => resolvers.push(resolve))
36
+ return name
37
+ })
38
+
39
+ const p1 = createTask('a')
40
+ const p2 = createTask('b')
41
+ const p3 = createTask('c')
42
+
43
+ await sleepAsync(10)
44
+
45
+ expect(running).toEqual(['a', 'b'])
46
+ expect(s.runningCount.getValue()).toBe(2)
47
+ expect(s.pendingCount.getValue()).toBe(1)
48
+
49
+ resolvers[0]()
50
+ await p1
51
+
52
+ await sleepAsync(10)
53
+
54
+ expect(running).toEqual(['a', 'b', 'c'])
55
+ expect(s.runningCount.getValue()).toBe(2)
56
+ expect(s.pendingCount.getValue()).toBe(0)
57
+
58
+ resolvers[1]()
59
+ resolvers[2]()
60
+ await Promise.all([p2, p3])
61
+
62
+ expect(s.completedCount.getValue()).toBe(3)
63
+ expect(s.runningCount.getValue()).toBe(0)
64
+ s[Symbol.dispose]()
65
+ })
66
+
67
+ it('should propagate task rejection to the caller and continue processing', async () => {
68
+ const s = new Semaphore(1)
69
+ const taskError = new Error('task failed')
70
+
71
+ const p1 = s.execute(async () => {
72
+ throw taskError
73
+ })
74
+ const p2 = s.execute(async () => 'ok')
75
+
76
+ await expect(p1).rejects.toThrow('task failed')
77
+
78
+ const result = await p2
79
+ expect(result).toBe('ok')
80
+ expect(s.failedCount.getValue()).toBe(1)
81
+ expect(s.completedCount.getValue()).toBe(1)
82
+ s[Symbol.dispose]()
83
+ })
84
+
85
+ describe('ObservableValue counters', () => {
86
+ it('should update pendingCount and runningCount on transitions', async () => {
87
+ const s = new Semaphore(1)
88
+ const pendingChanges: number[] = []
89
+ const runningChanges: number[] = []
90
+
91
+ s.pendingCount.subscribe((v) => {
92
+ pendingChanges.push(v)
93
+ })
94
+ s.runningCount.subscribe((v) => {
95
+ runningChanges.push(v)
96
+ })
97
+
98
+ let resolve!: () => void
99
+ const p1 = s.execute(async () => {
100
+ await new Promise<void>((r) => (resolve = r))
101
+ })
102
+
103
+ const p2 = s.execute(async () => 'done')
104
+
105
+ await sleepAsync(10)
106
+
107
+ expect(pendingChanges).toContain(1)
108
+ expect(runningChanges).toContain(1)
109
+
110
+ resolve()
111
+ await p1
112
+ await sleepAsync(10)
113
+ await p2
114
+
115
+ expect(s.pendingCount.getValue()).toBe(0)
116
+ expect(s.runningCount.getValue()).toBe(0)
117
+ expect(s.completedCount.getValue()).toBe(2)
118
+ s[Symbol.dispose]()
119
+ })
120
+
121
+ it('should update completedCount and failedCount correctly', async () => {
122
+ const s = new Semaphore(2)
123
+
124
+ await s.execute(async () => 'ok')
125
+ await s
126
+ .execute(async () => {
127
+ throw new Error('fail')
128
+ })
129
+ .catch(() => {})
130
+
131
+ expect(s.completedCount.getValue()).toBe(1)
132
+ expect(s.failedCount.getValue()).toBe(1)
133
+ s[Symbol.dispose]()
134
+ })
135
+ })
136
+
137
+ describe('AbortSignal support', () => {
138
+ it('should abort a pending task when the caller signal aborts', async () => {
139
+ const s = new Semaphore(1)
140
+ let resolve!: () => void
141
+
142
+ const p1 = s.execute(async () => {
143
+ await new Promise<void>((r) => (resolve = r))
144
+ })
145
+
146
+ const controller = new AbortController()
147
+ const p2 = s.execute(async () => 'should not run', { signal: controller.signal })
148
+
149
+ await sleepAsync(10)
150
+ expect(s.pendingCount.getValue()).toBe(1)
151
+
152
+ controller.abort(new Error('cancelled'))
153
+ await expect(p2).rejects.toThrow('cancelled')
154
+ expect(s.pendingCount.getValue()).toBe(0)
155
+
156
+ resolve()
157
+ await p1
158
+ s[Symbol.dispose]()
159
+ })
160
+
161
+ it('should reject immediately if the caller signal is already aborted', async () => {
162
+ const s = new Semaphore(1)
163
+ const controller = new AbortController()
164
+ controller.abort(new Error('pre-aborted'))
165
+
166
+ await expect(s.execute(async () => 'should not run', { signal: controller.signal })).rejects.toThrow(
167
+ 'pre-aborted',
168
+ )
169
+
170
+ expect(s.pendingCount.getValue()).toBe(0)
171
+ expect(s.runningCount.getValue()).toBe(0)
172
+ s[Symbol.dispose]()
173
+ })
174
+
175
+ it('should clean up the caller signal listener when the task completes normally', async () => {
176
+ const s = new Semaphore(1)
177
+ const controller = new AbortController()
178
+
179
+ const removeSpy = vi.spyOn(controller.signal, 'removeEventListener')
180
+
181
+ await s.execute(async () => 'done', { signal: controller.signal })
182
+
183
+ expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function))
184
+ removeSpy.mockRestore()
185
+ s[Symbol.dispose]()
186
+ })
187
+
188
+ it('should abort the signal passed to a running task when the caller signal aborts', async () => {
189
+ const s = new Semaphore(1)
190
+ const signalAborted = vi.fn()
191
+
192
+ const controller = new AbortController()
193
+ const p = s.execute(
194
+ async ({ signal }) => {
195
+ signal.addEventListener('abort', signalAborted)
196
+ await new Promise<void>((resolve) => {
197
+ signal.addEventListener('abort', () => resolve())
198
+ })
199
+ throw signal.reason
200
+ },
201
+ { signal: controller.signal },
202
+ )
203
+
204
+ await sleepAsync(10)
205
+ expect(s.runningCount.getValue()).toBe(1)
206
+
207
+ controller.abort(new Error('stop'))
208
+ await expect(p).rejects.toThrow('stop')
209
+ expect(signalAborted).toBeCalledTimes(1)
210
+ s[Symbol.dispose]()
211
+ })
212
+ })
213
+
214
+ describe('EventHub events', () => {
215
+ it('should emit taskStarted when a task begins running', async () => {
216
+ const s = new Semaphore(1)
217
+ const listener = vi.fn()
218
+ s.subscribe('taskStarted', listener)
219
+
220
+ await s.execute(async () => 'done')
221
+
222
+ expect(listener).toBeCalledTimes(1)
223
+ s[Symbol.dispose]()
224
+ })
225
+
226
+ it('should emit taskCompleted when a task resolves', async () => {
227
+ const s = new Semaphore(1)
228
+ const listener = vi.fn()
229
+ s.subscribe('taskCompleted', listener)
230
+
231
+ await s.execute(async () => 'done')
232
+
233
+ expect(listener).toBeCalledTimes(1)
234
+ s[Symbol.dispose]()
235
+ })
236
+
237
+ it('should emit taskFailed with the error when a task rejects', async () => {
238
+ const s = new Semaphore(1)
239
+ const listener = vi.fn()
240
+ s.subscribe('taskFailed', listener)
241
+
242
+ const taskError = new Error('boom')
243
+ await s
244
+ .execute(async () => {
245
+ throw taskError
246
+ })
247
+ .catch(() => {})
248
+
249
+ expect(listener).toBeCalledTimes(1)
250
+ expect(listener).toBeCalledWith({ error: taskError })
251
+ s[Symbol.dispose]()
252
+ })
253
+
254
+ it('should emit events in correct order for queued tasks', async () => {
255
+ const s = new Semaphore(1)
256
+ const events: string[] = []
257
+
258
+ s.subscribe('taskStarted', () => {
259
+ events.push('started')
260
+ })
261
+ s.subscribe('taskCompleted', () => {
262
+ events.push('completed')
263
+ })
264
+
265
+ await Promise.all([s.execute(async () => 'a'), s.execute(async () => 'b')])
266
+
267
+ await sleepAsync(10)
268
+
269
+ expect(events).toEqual(['started', 'completed', 'started', 'completed'])
270
+ s[Symbol.dispose]()
271
+ })
272
+ })
273
+
274
+ describe('setMaxConcurrent', () => {
275
+ it('should return the updated value from getMaxConcurrent', () => {
276
+ const s = new Semaphore(2)
277
+ s.setMaxConcurrent(5)
278
+ expect(s.getMaxConcurrent()).toBe(5)
279
+ s[Symbol.dispose]()
280
+ })
281
+
282
+ it('should throw when given a non-positive integer', () => {
283
+ const s = new Semaphore(2)
284
+ expect(() => s.setMaxConcurrent(0)).toThrow('maxConcurrent must be a positive integer')
285
+ expect(() => s.setMaxConcurrent(-1)).toThrow('maxConcurrent must be a positive integer')
286
+ expect(() => s.setMaxConcurrent(1.5)).toThrow('maxConcurrent must be a positive integer')
287
+ s[Symbol.dispose]()
288
+ })
289
+
290
+ it('should immediately start queued tasks when increased', async () => {
291
+ const s = new Semaphore(1)
292
+ const running: string[] = []
293
+ const resolvers: Array<() => void> = []
294
+
295
+ const createTask = (name: string) =>
296
+ s.execute(async () => {
297
+ running.push(name)
298
+ await new Promise<void>((resolve) => resolvers.push(resolve))
299
+ return name
300
+ })
301
+
302
+ const p1 = createTask('a')
303
+ const p2 = createTask('b')
304
+ const p3 = createTask('c')
305
+
306
+ await sleepAsync(10)
307
+ expect(running).toEqual(['a'])
308
+ expect(s.runningCount.getValue()).toBe(1)
309
+ expect(s.pendingCount.getValue()).toBe(2)
310
+
311
+ s.setMaxConcurrent(3)
312
+
313
+ await sleepAsync(10)
314
+ expect(running).toEqual(['a', 'b', 'c'])
315
+ expect(s.runningCount.getValue()).toBe(3)
316
+ expect(s.pendingCount.getValue()).toBe(0)
317
+
318
+ resolvers.forEach((r) => r())
319
+ await Promise.all([p1, p2, p3])
320
+ s[Symbol.dispose]()
321
+ })
322
+
323
+ it('should not abort running tasks when decreased', async () => {
324
+ const s = new Semaphore(3)
325
+ const resolvers: Array<() => void> = []
326
+
327
+ const createTask = () =>
328
+ s.execute(async () => {
329
+ await new Promise<void>((resolve) => resolvers.push(resolve))
330
+ })
331
+
332
+ const p1 = createTask()
333
+ const p2 = createTask()
334
+ const p3 = createTask()
335
+
336
+ await sleepAsync(10)
337
+ expect(s.runningCount.getValue()).toBe(3)
338
+
339
+ s.setMaxConcurrent(1)
340
+
341
+ expect(s.runningCount.getValue()).toBe(3)
342
+
343
+ resolvers.forEach((r) => r())
344
+ await Promise.all([p1, p2, p3])
345
+ expect(s.completedCount.getValue()).toBe(3)
346
+ s[Symbol.dispose]()
347
+ })
348
+
349
+ it('should not start new tasks until running count drops below new lower limit', async () => {
350
+ const s = new Semaphore(2)
351
+ const running: string[] = []
352
+ const resolvers: Array<() => void> = []
353
+
354
+ const createTask = (name: string) =>
355
+ s.execute(async () => {
356
+ running.push(name)
357
+ await new Promise<void>((resolve) => resolvers.push(resolve))
358
+ return name
359
+ })
360
+
361
+ const p1 = createTask('a')
362
+ const p2 = createTask('b')
363
+ const p3 = createTask('c')
364
+
365
+ await sleepAsync(10)
366
+ expect(running).toEqual(['a', 'b'])
367
+ expect(s.pendingCount.getValue()).toBe(1)
368
+
369
+ s.setMaxConcurrent(1)
370
+
371
+ resolvers[0]()
372
+ await p1
373
+ await sleepAsync(10)
374
+
375
+ expect(running).toEqual(['a', 'b'])
376
+ expect(s.pendingCount.getValue()).toBe(1)
377
+
378
+ resolvers[1]()
379
+ await p2
380
+ await sleepAsync(10)
381
+
382
+ expect(running).toEqual(['a', 'b', 'c'])
383
+ expect(s.pendingCount.getValue()).toBe(0)
384
+
385
+ resolvers[2]()
386
+ await p3
387
+ s[Symbol.dispose]()
388
+ })
389
+ })
390
+
391
+ describe('Disposal', () => {
392
+ it('should reject all pending tasks with SemaphoreDisposedError', async () => {
393
+ const s = new Semaphore(1)
394
+ let resolve!: () => void
395
+
396
+ const p1 = s.execute(async () => {
397
+ await new Promise<void>((r) => (resolve = r))
398
+ })
399
+
400
+ const p2 = s.execute(async () => 'pending1')
401
+ const p3 = s.execute(async () => 'pending2')
402
+
403
+ await sleepAsync(10)
404
+ expect(s.pendingCount.getValue()).toBe(2)
405
+
406
+ s[Symbol.dispose]()
407
+
408
+ await expect(p2).rejects.toThrow('Semaphore already disposed')
409
+ await expect(p3).rejects.toThrow('Semaphore already disposed')
410
+ await expect(p2).rejects.toBeInstanceOf(SemaphoreDisposedError)
411
+ await expect(p3).rejects.toBeInstanceOf(SemaphoreDisposedError)
412
+
413
+ resolve()
414
+ await p1
415
+ })
416
+
417
+ it('should abort signals of running tasks on disposal', async () => {
418
+ const s = new Semaphore(1)
419
+ const signalAborted = vi.fn()
420
+
421
+ const p = s.execute(async ({ signal }) => {
422
+ signal.addEventListener('abort', signalAborted)
423
+ await new Promise<void>((resolve) => {
424
+ signal.addEventListener('abort', () => resolve())
425
+ })
426
+ throw signal.reason
427
+ })
428
+
429
+ await sleepAsync(10)
430
+ expect(s.runningCount.getValue()).toBe(1)
431
+
432
+ s[Symbol.dispose]()
433
+
434
+ await expect(p).rejects.toBeInstanceOf(SemaphoreDisposedError)
435
+ expect(signalAborted).toBeCalledTimes(1)
436
+ })
437
+
438
+ it('should throw SemaphoreDisposedError when calling execute() after disposal', () => {
439
+ const s = new Semaphore(1)
440
+ s[Symbol.dispose]()
441
+
442
+ expect(() => s.execute(async () => 'too late')).toThrow('Semaphore already disposed')
443
+ expect(() => s.execute(async () => 'too late')).toThrow(SemaphoreDisposedError)
444
+ })
445
+
446
+ it('should dispose all ObservableValues', () => {
447
+ const s = new Semaphore(1)
448
+ s[Symbol.dispose]()
449
+
450
+ expect(s.pendingCount.isDisposed).toBe(true)
451
+ expect(s.runningCount.isDisposed).toBe(true)
452
+ expect(s.completedCount.isDisposed).toBe(true)
453
+ expect(s.failedCount.isDisposed).toBe(true)
454
+ })
455
+
456
+ it('should clear event listeners via super', () => {
457
+ const s = new Semaphore(1)
458
+ const listener = vi.fn()
459
+ s.subscribe('taskStarted', listener)
460
+
461
+ s[Symbol.dispose]()
462
+
463
+ s.emit('taskStarted', undefined)
464
+ expect(listener).not.toBeCalled()
465
+ })
466
+ })
467
+ })
@@ -0,0 +1,237 @@
1
+ import { EventHub, type ListenerErrorPayload } from './event-hub.js'
2
+ import { ObservableValue } from './observable-value.js'
3
+
4
+ /**
5
+ * Error thrown when you try to execute on a semaphore that is already disposed,
6
+ * or when pending tasks are rejected due to disposal.
7
+ */
8
+ export class SemaphoreDisposedError extends Error {
9
+ constructor() {
10
+ super('Semaphore already disposed')
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Event map for the Semaphore's EventHub.
16
+ */
17
+ export type SemaphoreEvents = {
18
+ /** Fired when a queued task begins execution */
19
+ taskStarted: undefined
20
+ /** Fired when a running task resolves successfully */
21
+ taskCompleted: undefined
22
+ /** Fired when a running task rejects, carrying the thrown error */
23
+ taskFailed: { error: unknown }
24
+ /** Fired when an event listener throws during emission */
25
+ onListenerError: ListenerErrorPayload
26
+ }
27
+
28
+ type QueuedTask<T = unknown> = {
29
+ task: (options: { signal: AbortSignal }) => Promise<T>
30
+ resolve: (value: T) => void
31
+ reject: (reason: unknown) => void
32
+ abortController: AbortController
33
+ callerSignal?: AbortSignal
34
+ callerAbortHandler?: () => void
35
+ }
36
+
37
+ /**
38
+ * An async semaphore that limits concurrent task execution to a fixed number of slots.
39
+ *
40
+ * Extends {@link EventHub} with {@link SemaphoreEvents} for per-task lifecycle events
41
+ * (`taskStarted`, `taskCompleted`, `taskFailed`).
42
+ *
43
+ * Exposes individual {@link ObservableValue} counters for reactive state monitoring.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const results = await usingAsync(new Semaphore(3), async (semaphore) => {
48
+ * semaphore.pendingCount.subscribe((count) => console.log('Pending:', count))
49
+ * semaphore.subscribe('taskCompleted', () => console.log('A task completed'))
50
+ *
51
+ * return await Promise.all(
52
+ * urls.map((url) => semaphore.execute(({ signal }) => fetch(url, { signal }))),
53
+ * )
54
+ * })
55
+ * ```
56
+ */
57
+ export class Semaphore extends EventHub<SemaphoreEvents> {
58
+ private readonly queue: Array<QueuedTask<any>> = []
59
+ private readonly running = new Set<QueuedTask>()
60
+ private disposed = false
61
+
62
+ /** The number of tasks waiting in the queue to be started */
63
+ public readonly pendingCount = new ObservableValue<number>(0)
64
+ /** The number of tasks currently executing */
65
+ public readonly runningCount = new ObservableValue<number>(0)
66
+ /** The total number of tasks that have resolved successfully */
67
+ public readonly completedCount = new ObservableValue<number>(0)
68
+ /** The total number of tasks that have rejected */
69
+ public readonly failedCount = new ObservableValue<number>(0)
70
+
71
+ private _maxConcurrent: number
72
+
73
+ /**
74
+ * @param maxConcurrent The maximum number of tasks that can run concurrently
75
+ */
76
+ constructor(maxConcurrent: number) {
77
+ super()
78
+ this._maxConcurrent = maxConcurrent
79
+ }
80
+
81
+ /**
82
+ * Returns the current maximum number of tasks that can run concurrently.
83
+ * @returns The current concurrency limit
84
+ */
85
+ public getMaxConcurrent(): number {
86
+ return this._maxConcurrent
87
+ }
88
+
89
+ /**
90
+ * Updates the maximum number of tasks that can run concurrently.
91
+ *
92
+ * If the new limit is higher than the current one, queued tasks will
93
+ * be started immediately to fill the new slots.
94
+ * If the new limit is lower, already-running tasks will not be aborted,
95
+ * but no new tasks will start until the running count drops below the new limit.
96
+ *
97
+ * @param value The new concurrency limit (must be a positive integer)
98
+ */
99
+ public setMaxConcurrent(value: number): void {
100
+ if (!Number.isInteger(value) || value < 1) {
101
+ throw new Error('maxConcurrent must be a positive integer')
102
+ }
103
+ this._maxConcurrent = value
104
+ this.drain()
105
+ }
106
+
107
+ /**
108
+ * Queues a task for execution. Resolves or rejects with the task's own result.
109
+ *
110
+ * The task function receives an `AbortSignal` that is aborted when:
111
+ * - the caller's signal aborts (if provided via `options.signal`)
112
+ * - the semaphore is disposed
113
+ *
114
+ * @param task The async function to execute
115
+ * @param options Optional configuration including an AbortSignal
116
+ * @returns A promise that resolves/rejects with the task's result
117
+ */
118
+ public execute<T>(
119
+ task: (options: { signal: AbortSignal }) => Promise<T>,
120
+ options?: { signal?: AbortSignal },
121
+ ): Promise<T> {
122
+ if (this.disposed) {
123
+ throw new SemaphoreDisposedError()
124
+ }
125
+
126
+ if (options?.signal?.aborted) {
127
+ return Promise.reject(options.signal.reason as Error)
128
+ }
129
+
130
+ return new Promise<T>((resolve, reject) => {
131
+ const abortController = new AbortController()
132
+
133
+ const entry: QueuedTask<T> = { task, resolve, reject, abortController }
134
+
135
+ if (options?.signal) {
136
+ const callerAbortHandler = () => {
137
+ abortController.abort(options.signal!.reason)
138
+ this.removePending(entry)
139
+ }
140
+ entry.callerSignal = options.signal
141
+ entry.callerAbortHandler = callerAbortHandler
142
+ options.signal.addEventListener('abort', callerAbortHandler, { once: true })
143
+ }
144
+
145
+ this.queue.push(entry)
146
+ this.pendingCount.setValue(this.pendingCount.getValue() + 1)
147
+
148
+ this.drain()
149
+ })
150
+ }
151
+
152
+ private removePending<T>(entry: QueuedTask<T>): void {
153
+ const idx = this.queue.indexOf(entry)
154
+ if (idx !== -1) {
155
+ this.queue.splice(idx, 1)
156
+ this.pendingCount.setValue(this.pendingCount.getValue() - 1)
157
+ entry.reject(entry.abortController.signal.reason)
158
+ }
159
+ }
160
+
161
+ private drain(): void {
162
+ while (this.running.size < this._maxConcurrent && this.queue.length > 0) {
163
+ const entry = this.queue.shift()!
164
+ this.pendingCount.setValue(this.pendingCount.getValue() - 1)
165
+ this.startTask(entry)
166
+ }
167
+ }
168
+
169
+ private cleanupCallerSignal(entry: QueuedTask): void {
170
+ if (entry.callerSignal && entry.callerAbortHandler) {
171
+ entry.callerSignal.removeEventListener('abort', entry.callerAbortHandler)
172
+ entry.callerSignal = undefined
173
+ entry.callerAbortHandler = undefined
174
+ }
175
+ }
176
+
177
+ private startTask(entry: QueuedTask): void {
178
+ this.running.add(entry)
179
+ this.runningCount.setValue(this.runningCount.getValue() + 1)
180
+ this.emit('taskStarted', undefined)
181
+
182
+ entry
183
+ .task({ signal: entry.abortController.signal })
184
+ .then(
185
+ (value) => {
186
+ this.running.delete(entry)
187
+ this.cleanupCallerSignal(entry)
188
+ if (!this.disposed) {
189
+ this.runningCount.setValue(this.runningCount.getValue() - 1)
190
+ this.completedCount.setValue(this.completedCount.getValue() + 1)
191
+ this.emit('taskCompleted', undefined)
192
+ }
193
+ entry.resolve(value)
194
+ },
195
+ (error: unknown) => {
196
+ this.running.delete(entry)
197
+ this.cleanupCallerSignal(entry)
198
+ if (!this.disposed) {
199
+ this.runningCount.setValue(this.runningCount.getValue() - 1)
200
+ this.failedCount.setValue(this.failedCount.getValue() + 1)
201
+ this.emit('taskFailed', { error })
202
+ }
203
+ entry.reject(error)
204
+ },
205
+ )
206
+ .finally(() => {
207
+ if (!this.disposed) {
208
+ this.drain()
209
+ }
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Disposes the semaphore: rejects all pending tasks with {@link SemaphoreDisposedError},
215
+ * aborts the signal of every running task, and disposes all observable counters and event listeners.
216
+ */
217
+ public override [Symbol.dispose](): void {
218
+ this.disposed = true
219
+
220
+ for (const entry of [...this.queue]) {
221
+ this.queue.shift()
222
+ entry.reject(new SemaphoreDisposedError())
223
+ }
224
+ this.pendingCount.setValue(0)
225
+
226
+ for (const entry of this.running) {
227
+ entry.abortController.abort(new SemaphoreDisposedError())
228
+ }
229
+
230
+ this.pendingCount[Symbol.dispose]()
231
+ this.runningCount[Symbol.dispose]()
232
+ this.completedCount[Symbol.dispose]()
233
+ this.failedCount[Symbol.dispose]()
234
+
235
+ super[Symbol.dispose]()
236
+ }
237
+ }