abxbus 2.5.1 → 2.5.4
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.
- package/dist/cjs/CoreClient.d.ts +167 -0
- package/dist/cjs/CoreEventBus.d.ts +334 -0
- package/dist/cjs/base_event.d.ts +211 -0
- package/dist/cjs/bridge_jsonl.d.ts +26 -0
- package/dist/cjs/bridge_nats.d.ts +20 -0
- package/dist/cjs/bridge_postgres.d.ts +31 -0
- package/dist/cjs/bridge_redis.d.ts +34 -0
- package/dist/cjs/bridge_sqlite.d.ts +30 -0
- package/dist/cjs/event_bus.d.ts +125 -0
- package/dist/cjs/event_handler.d.ts +139 -0
- package/dist/cjs/event_history.d.ts +45 -0
- package/dist/cjs/event_result.d.ts +86 -0
- package/dist/cjs/lock_manager.d.ts +70 -0
- package/dist/cjs/retry.d.ts +8 -1
- package/dist/cjs/retry.js +283 -14
- package/dist/cjs/retry.js.map +2 -2
- package/dist/esm/retry.js +283 -14
- package/dist/esm/retry.js.map +2 -2
- package/dist/types/CoreClient.d.ts +167 -0
- package/dist/types/CoreEventBus.d.ts +334 -0
- package/dist/types/base_event.d.ts +211 -0
- package/dist/types/bridge_jsonl.d.ts +26 -0
- package/dist/types/bridge_nats.d.ts +20 -0
- package/dist/types/bridge_postgres.d.ts +31 -0
- package/dist/types/bridge_redis.d.ts +34 -0
- package/dist/types/bridge_sqlite.d.ts +30 -0
- package/dist/types/event_bus.d.ts +125 -0
- package/dist/types/event_handler.d.ts +139 -0
- package/dist/types/event_history.d.ts +45 -0
- package/dist/types/event_result.d.ts +86 -0
- package/dist/types/lock_manager.d.ts +70 -0
- package/dist/types/retry.d.ts +8 -1
- package/package.json +1 -1
- package/src/retry.ts +368 -22
package/src/retry.ts
CHANGED
|
@@ -7,6 +7,18 @@ type MultiprocessLockHandle = {
|
|
|
7
7
|
release: () => Promise<void>
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
type SyncMultiprocessLockHandle = {
|
|
11
|
+
release: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type AnyFunction = (this: any, ...args: any[]) => any
|
|
15
|
+
type LegacyMethodDescriptor = TypedPropertyDescriptor<AnyFunction>
|
|
16
|
+
type RetryDecorator = {
|
|
17
|
+
<T extends AnyFunction>(target: T): T
|
|
18
|
+
<T extends AnyFunction>(target: T, context: ClassMethodDecoratorContext): T
|
|
19
|
+
(target: object, property_key: string | symbol, descriptor: LegacyMethodDescriptor): LegacyMethodDescriptor
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
const MULTIPROCESS_SEMAPHORE_DIRNAME = 'browser_use_semaphores'
|
|
11
23
|
const MULTIPROCESS_STALE_LOCK_MS = 5 * 60 * 1000
|
|
12
24
|
|
|
@@ -144,12 +156,19 @@ class RetrySemaphore {
|
|
|
144
156
|
this.waiters = []
|
|
145
157
|
}
|
|
146
158
|
|
|
147
|
-
|
|
159
|
+
tryAcquire(): boolean {
|
|
148
160
|
if (this.size === Infinity) {
|
|
149
|
-
return
|
|
161
|
+
return true
|
|
150
162
|
}
|
|
151
163
|
if (this.inUse < this.size) {
|
|
152
164
|
this.inUse += 1
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
return false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async acquire(): Promise<void> {
|
|
171
|
+
if (this.tryAcquire()) {
|
|
153
172
|
return
|
|
154
173
|
}
|
|
155
174
|
await new Promise<void>((resolve) => {
|
|
@@ -189,13 +208,17 @@ export function clearSemaphoreRegistry(): void {
|
|
|
189
208
|
|
|
190
209
|
// ─── retry() decorator / higher-order wrapper ────────────────────────────────
|
|
191
210
|
//
|
|
192
|
-
// Usage as a higher-order function (works on
|
|
211
|
+
// Usage as a higher-order function (works on async and sync functions):
|
|
193
212
|
//
|
|
194
213
|
// const fetchWithRetry = retry({ max_attempts: 3, retry_after: 1 })(async (url: string) => {
|
|
195
214
|
// return await fetch(url)
|
|
196
215
|
// })
|
|
197
216
|
//
|
|
198
|
-
//
|
|
217
|
+
// const readWithRetry = retry({ max_attempts: 3, retry_after: 1 })((path: string) => {
|
|
218
|
+
// return readFileSync(path, 'utf8')
|
|
219
|
+
// })
|
|
220
|
+
//
|
|
221
|
+
// Usage as a TC39 Stage 3 or legacy experimental decorator on class methods:
|
|
199
222
|
//
|
|
200
223
|
// class ApiClient {
|
|
201
224
|
// @retry({ max_attempts: 3, retry_after: 1 })
|
|
@@ -208,7 +231,7 @@ export function clearSemaphoreRegistry(): void {
|
|
|
208
231
|
// await riskyOperation(event.data)
|
|
209
232
|
// }))
|
|
210
233
|
|
|
211
|
-
export function retry(options: RetryOptions = {}) {
|
|
234
|
+
export function retry(options: RetryOptions = {}): RetryDecorator {
|
|
212
235
|
const {
|
|
213
236
|
max_attempts = 1,
|
|
214
237
|
retry_after = 0,
|
|
@@ -222,12 +245,65 @@ export function retry(options: RetryOptions = {}) {
|
|
|
222
245
|
semaphore_timeout,
|
|
223
246
|
} = options
|
|
224
247
|
|
|
225
|
-
|
|
248
|
+
const decorateFunction = <T extends AnyFunction>(target: T, _context?: ClassMethodDecoratorContext): T => {
|
|
226
249
|
const fn_name = target.name || (_context?.name as string) || 'anonymous'
|
|
227
250
|
const effective_max_attempts = Math.max(1, max_attempts)
|
|
228
251
|
const effective_retry_after = Math.max(0, retry_after)
|
|
229
252
|
|
|
230
|
-
|
|
253
|
+
const shouldRetry = (error: unknown): boolean => {
|
|
254
|
+
if (!retry_on_errors || retry_on_errors.length === 0) return true
|
|
255
|
+
return retry_on_errors.some((matcher) =>
|
|
256
|
+
typeof matcher === 'string'
|
|
257
|
+
? (error as Error)?.name === matcher
|
|
258
|
+
: matcher instanceof RegExp
|
|
259
|
+
? matcher.test(String(error))
|
|
260
|
+
: error instanceof matcher
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const asyncRetryDelay = async (attempt: number): Promise<void> => {
|
|
265
|
+
const delay_seconds = effective_retry_after * Math.pow(retry_backoff_factor, attempt - 1)
|
|
266
|
+
if (delay_seconds > 0) {
|
|
267
|
+
await sleep(delay_seconds * 1000)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const syncRetryDelay = (attempt: number): void => {
|
|
272
|
+
const delay_seconds = effective_retry_after * Math.pow(retry_backoff_factor, attempt - 1)
|
|
273
|
+
if (delay_seconds > 0) {
|
|
274
|
+
sleepSync(delay_seconds * 1000)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const runRetryLoopFromThenable = async (
|
|
279
|
+
this_arg: any,
|
|
280
|
+
args: any[],
|
|
281
|
+
first_thenable: PromiseLike<any>,
|
|
282
|
+
first_attempt: number
|
|
283
|
+
): Promise<any> => {
|
|
284
|
+
let current_result: any = first_thenable
|
|
285
|
+
for (let attempt = first_attempt; attempt <= effective_max_attempts; attempt++) {
|
|
286
|
+
try {
|
|
287
|
+
if (attempt !== first_attempt) {
|
|
288
|
+
current_result = target.apply(this_arg, args)
|
|
289
|
+
}
|
|
290
|
+
if (isThenable(current_result)) {
|
|
291
|
+
if (timeout != null && timeout > 0) {
|
|
292
|
+
return await _runWithTimeout(() => Promise.resolve(current_result), timeout * 1000, attempt)
|
|
293
|
+
}
|
|
294
|
+
return await current_result
|
|
295
|
+
}
|
|
296
|
+
return current_result
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (!shouldRetry(error)) throw error
|
|
299
|
+
if (attempt >= effective_max_attempts) throw error
|
|
300
|
+
await asyncRetryDelay(attempt)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
throw new Error(`retry(${fn_name}): unexpected end of retry loop`)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function asyncRetryWrapper(this: any, ...args: any[]): Promise<any> {
|
|
231
307
|
const base_name = typeof semaphore_name_option === 'function' ? semaphore_name_option(...args) : (semaphore_name_option ?? fn_name)
|
|
232
308
|
const sem_name = typeof base_name === 'string' ? base_name : String(base_name)
|
|
233
309
|
// ── Resolve scoped semaphore key at call time (uses `this` for class/instance scopes) ──
|
|
@@ -305,26 +381,13 @@ export function retry(options: RetryOptions = {}) {
|
|
|
305
381
|
return await Promise.resolve(target.apply(this, args))
|
|
306
382
|
}
|
|
307
383
|
} catch (error) {
|
|
308
|
-
|
|
309
|
-
if (retry_on_errors && retry_on_errors.length > 0) {
|
|
310
|
-
const is_retryable = retry_on_errors.some((matcher) =>
|
|
311
|
-
typeof matcher === 'string'
|
|
312
|
-
? (error as Error)?.name === matcher
|
|
313
|
-
: matcher instanceof RegExp
|
|
314
|
-
? matcher.test(String(error))
|
|
315
|
-
: error instanceof matcher
|
|
316
|
-
)
|
|
317
|
-
if (!is_retryable) throw error
|
|
318
|
-
}
|
|
384
|
+
if (!shouldRetry(error)) throw error
|
|
319
385
|
|
|
320
386
|
// Last attempt: rethrow
|
|
321
387
|
if (attempt >= effective_max_attempts) throw error
|
|
322
388
|
|
|
323
389
|
// Wait before next attempt with exponential backoff
|
|
324
|
-
|
|
325
|
-
if (delay_seconds > 0) {
|
|
326
|
-
await sleep(delay_seconds * 1000)
|
|
327
|
-
}
|
|
390
|
+
await asyncRetryDelay(attempt)
|
|
328
391
|
}
|
|
329
392
|
}
|
|
330
393
|
|
|
@@ -343,6 +406,89 @@ export function retry(options: RetryOptions = {}) {
|
|
|
343
406
|
}
|
|
344
407
|
}
|
|
345
408
|
|
|
409
|
+
function syncRetryWrapper(this: any, ...args: any[]): any {
|
|
410
|
+
const base_name = typeof semaphore_name_option === 'function' ? semaphore_name_option(...args) : (semaphore_name_option ?? fn_name)
|
|
411
|
+
const sem_name = typeof base_name === 'string' ? base_name : String(base_name)
|
|
412
|
+
const scoped_key = scopedSemaphoreKey(sem_name, semaphore_scope, this)
|
|
413
|
+
|
|
414
|
+
const held = getHeldSemaphores()
|
|
415
|
+
const needs_semaphore = semaphore_limit != null && semaphore_limit > 0
|
|
416
|
+
const is_reentrant = needs_semaphore && held.has(scoped_key)
|
|
417
|
+
|
|
418
|
+
let semaphore: RetrySemaphore | null = null
|
|
419
|
+
let multiprocess_lock: SyncMultiprocessLockHandle | null = null
|
|
420
|
+
let semaphore_acquired = false
|
|
421
|
+
|
|
422
|
+
if (needs_semaphore && !is_reentrant) {
|
|
423
|
+
const effective_sem_timeout =
|
|
424
|
+
semaphore_timeout != null ? semaphore_timeout : timeout != null ? timeout * Math.max(1, semaphore_limit! - 1) : null
|
|
425
|
+
|
|
426
|
+
if (semaphore_scope === 'multiprocess') {
|
|
427
|
+
if (isNodeRuntime()) {
|
|
428
|
+
multiprocess_lock = acquireMultiprocessSemaphoreSync(scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
|
|
429
|
+
semaphore_acquired = multiprocess_lock !== null
|
|
430
|
+
} else {
|
|
431
|
+
logMultiprocessFallbackOnce('multiprocess semaphores require a Node.js runtime; falling back to in-process global scope')
|
|
432
|
+
semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
|
|
433
|
+
semaphore_acquired = acquireSemaphoreSyncOrThrow(semaphore, scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
|
|
437
|
+
semaphore_acquired = acquireSemaphoreSyncOrThrow(semaphore, scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const new_held = new Set(held)
|
|
442
|
+
if (semaphore_acquired) {
|
|
443
|
+
new_held.add(scoped_key)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const release = (): void => {
|
|
447
|
+
if (semaphore_acquired && multiprocess_lock) {
|
|
448
|
+
multiprocess_lock.release()
|
|
449
|
+
} else if (semaphore_acquired && semaphore) {
|
|
450
|
+
semaphore.release()
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const runRetryLoop = (): any => {
|
|
455
|
+
for (let attempt = 1; attempt <= effective_max_attempts; attempt++) {
|
|
456
|
+
const attempt_started_at = Date.now()
|
|
457
|
+
try {
|
|
458
|
+
const result = target.apply(this, args)
|
|
459
|
+
if (isThenable(result)) {
|
|
460
|
+
return runRetryLoopFromThenable(this, args, result, attempt)
|
|
461
|
+
}
|
|
462
|
+
if (timeout != null && timeout > 0 && Date.now() - attempt_started_at > timeout * 1000) {
|
|
463
|
+
throw new RetryTimeoutError(`Timed out after ${timeout}s (attempt ${attempt})`, {
|
|
464
|
+
timeout_seconds: timeout,
|
|
465
|
+
attempt,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
return result
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (!shouldRetry(error)) throw error
|
|
471
|
+
if (attempt >= effective_max_attempts) throw error
|
|
472
|
+
syncRetryDelay(attempt)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
throw new Error(`retry(${fn_name}): unexpected end of retry loop`)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const result = runWithHeldSemaphores(new_held, runRetryLoop)
|
|
480
|
+
if (isThenable(result)) {
|
|
481
|
+
return Promise.resolve(result).finally(release)
|
|
482
|
+
}
|
|
483
|
+
release()
|
|
484
|
+
return result
|
|
485
|
+
} catch (error) {
|
|
486
|
+
release()
|
|
487
|
+
throw error
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const retryWrapper = isAsyncFunction(target) ? asyncRetryWrapper : syncRetryWrapper
|
|
346
492
|
Object.defineProperty(retryWrapper, 'name', { value: fn_name, configurable: true })
|
|
347
493
|
if (_context?.kind === 'method' && typeof _context.addInitializer === 'function') {
|
|
348
494
|
_context.addInitializer(function (this: unknown) {
|
|
@@ -354,6 +500,22 @@ export function retry(options: RetryOptions = {}) {
|
|
|
354
500
|
}
|
|
355
501
|
return retryWrapper as unknown as T
|
|
356
502
|
}
|
|
503
|
+
|
|
504
|
+
function decorator<T extends AnyFunction>(
|
|
505
|
+
target: T | object,
|
|
506
|
+
context_or_property_key?: ClassMethodDecoratorContext | string | symbol,
|
|
507
|
+
descriptor?: LegacyMethodDescriptor
|
|
508
|
+
): T | LegacyMethodDescriptor {
|
|
509
|
+
if (descriptor?.value && typeof descriptor.value === 'function') {
|
|
510
|
+
descriptor.value = decorateFunction(descriptor.value)
|
|
511
|
+
return descriptor
|
|
512
|
+
}
|
|
513
|
+
if (typeof target === 'function') {
|
|
514
|
+
return decorateFunction(target as T, typeof context_or_property_key === 'object' ? context_or_property_key : undefined)
|
|
515
|
+
}
|
|
516
|
+
throw new TypeError('retry() can only decorate functions and class methods')
|
|
517
|
+
}
|
|
518
|
+
return decorator as RetryDecorator
|
|
357
519
|
}
|
|
358
520
|
|
|
359
521
|
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
@@ -397,6 +559,16 @@ function findDecoratedMethodOwnerName(
|
|
|
397
559
|
return null
|
|
398
560
|
}
|
|
399
561
|
|
|
562
|
+
function isAsyncFunction(fn: AnyFunction): boolean {
|
|
563
|
+
return Object.prototype.toString.call(fn) === '[object AsyncFunction]'
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
567
|
+
return (
|
|
568
|
+
(typeof value === 'object' || typeof value === 'function') && value !== null && typeof (value as { then?: unknown }).then === 'function'
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
400
572
|
/**
|
|
401
573
|
* Try to acquire a semaphore within a timeout. Returns true if acquired, false if timed out.
|
|
402
574
|
* If the semaphore is acquired after the timeout (due to the waiter remaining queued),
|
|
@@ -426,12 +598,70 @@ async function acquireWithTimeout(semaphore: RetrySemaphore, timeout_ms: number)
|
|
|
426
598
|
})
|
|
427
599
|
}
|
|
428
600
|
|
|
601
|
+
function acquireSemaphoreSyncOrThrow(
|
|
602
|
+
semaphore: RetrySemaphore,
|
|
603
|
+
scoped_key: string,
|
|
604
|
+
semaphore_limit: number,
|
|
605
|
+
timeout_seconds: number | null,
|
|
606
|
+
semaphore_lax: boolean
|
|
607
|
+
): boolean {
|
|
608
|
+
const acquired = acquireSemaphoreSync(semaphore, timeout_seconds == null ? null : timeout_seconds * 1000)
|
|
609
|
+
if (acquired) return true
|
|
610
|
+
|
|
611
|
+
if (!semaphore_lax) {
|
|
612
|
+
throw new SemaphoreTimeoutError(`Failed to acquire semaphore "${scoped_key}" within ${timeout_seconds}s (limit=${semaphore_limit})`, {
|
|
613
|
+
semaphore_name: scoped_key,
|
|
614
|
+
semaphore_limit,
|
|
615
|
+
timeout_seconds: timeout_seconds ?? 0,
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
return false
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function acquireSemaphoreSync(semaphore: RetrySemaphore, timeout_ms: number | null): boolean {
|
|
622
|
+
if (semaphore.tryAcquire()) return true
|
|
623
|
+
|
|
624
|
+
const start = Date.now()
|
|
625
|
+
while (true) {
|
|
626
|
+
if (timeout_ms != null && timeout_ms > 0 && Date.now() - start >= timeout_ms) {
|
|
627
|
+
return false
|
|
628
|
+
}
|
|
629
|
+
sleepSync(10)
|
|
630
|
+
if (semaphore.tryAcquire()) return true
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
429
634
|
function logMultiprocessFallbackOnce(reason: string): void {
|
|
430
635
|
if (multiprocess_fallback_reason_logged === reason) return
|
|
431
636
|
multiprocess_fallback_reason_logged = reason
|
|
432
637
|
console.warn(`[abxbus.retry] ${reason}`)
|
|
433
638
|
}
|
|
434
639
|
|
|
640
|
+
function importNodeModuleSync(specifier: string): any {
|
|
641
|
+
const maybe_process = (
|
|
642
|
+
globalThis as {
|
|
643
|
+
process?: { getBuiltinModule?: (name: string) => any }
|
|
644
|
+
}
|
|
645
|
+
).process
|
|
646
|
+
const get_builtin_module = maybe_process?.getBuiltinModule
|
|
647
|
+
const bare_specifier = specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier
|
|
648
|
+
|
|
649
|
+
if (typeof get_builtin_module === 'function') {
|
|
650
|
+
const builtin = get_builtin_module(bare_specifier) ?? get_builtin_module(specifier)
|
|
651
|
+
if (builtin) return builtin
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let require_fn: ((name: string) => any) | undefined
|
|
655
|
+
try {
|
|
656
|
+
require_fn = Function('return typeof require === "function" ? require : undefined')() as ((name: string) => any) | undefined
|
|
657
|
+
} catch {
|
|
658
|
+
require_fn = undefined
|
|
659
|
+
}
|
|
660
|
+
if (require_fn) return require_fn(specifier)
|
|
661
|
+
|
|
662
|
+
throw new Error('[abxbus.retry] synchronous Node.js module loading is unavailable; cannot use sync multiprocess semaphores')
|
|
663
|
+
}
|
|
664
|
+
|
|
435
665
|
async function importNodeModule(specifier: string): Promise<any> {
|
|
436
666
|
const dynamic_import = Function('module_name', 'return import(module_name)') as (module_name: string) => Promise<unknown>
|
|
437
667
|
return dynamic_import(specifier) as Promise<any>
|
|
@@ -537,6 +767,104 @@ async function acquireMultiprocessSemaphore(
|
|
|
537
767
|
return null
|
|
538
768
|
}
|
|
539
769
|
|
|
770
|
+
function acquireMultiprocessSemaphoreSync(
|
|
771
|
+
scoped_key: string,
|
|
772
|
+
semaphore_limit: number,
|
|
773
|
+
semaphore_timeout_seconds: number | null,
|
|
774
|
+
semaphore_lax: boolean
|
|
775
|
+
): SyncMultiprocessLockHandle | null {
|
|
776
|
+
const crypto = importNodeModuleSync('node:crypto')
|
|
777
|
+
const fs = importNodeModuleSync('node:fs')
|
|
778
|
+
const os = importNodeModuleSync('node:os')
|
|
779
|
+
const path = importNodeModuleSync('node:path')
|
|
780
|
+
const semaphore_directory = path.join(os.tmpdir(), MULTIPROCESS_SEMAPHORE_DIRNAME)
|
|
781
|
+
const lock_prefix = crypto.createHash('sha256').update(scoped_key).digest('hex').slice(0, 40)
|
|
782
|
+
fs.mkdirSync(semaphore_directory, { recursive: true })
|
|
783
|
+
|
|
784
|
+
const start = Date.now()
|
|
785
|
+
let retry_delay_ms = 100
|
|
786
|
+
|
|
787
|
+
while (true) {
|
|
788
|
+
const elapsed_ms = Date.now() - start
|
|
789
|
+
const remaining_ms =
|
|
790
|
+
semaphore_timeout_seconds != null && semaphore_timeout_seconds > 0 ? semaphore_timeout_seconds * 1000 - elapsed_ms : null
|
|
791
|
+
|
|
792
|
+
if (remaining_ms != null && remaining_ms <= 0) {
|
|
793
|
+
break
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
for (let slot = 0; slot < semaphore_limit; slot++) {
|
|
797
|
+
const slot_file = path.join(semaphore_directory, `${lock_prefix}.${String(slot).padStart(2, '0')}.lock`)
|
|
798
|
+
const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
const fd = fs.openSync(slot_file, 'wx', 0o600)
|
|
802
|
+
try {
|
|
803
|
+
fs.writeFileSync(
|
|
804
|
+
fd,
|
|
805
|
+
JSON.stringify({
|
|
806
|
+
token,
|
|
807
|
+
pid: process.pid,
|
|
808
|
+
semaphore_name: scoped_key,
|
|
809
|
+
created_at_ms: Date.now(),
|
|
810
|
+
}),
|
|
811
|
+
'utf8'
|
|
812
|
+
)
|
|
813
|
+
} finally {
|
|
814
|
+
fs.closeSync(fd)
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
release: () => {
|
|
818
|
+
try {
|
|
819
|
+
const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
|
|
820
|
+
const current_owner = raw ? (JSON.parse(raw) as { token?: unknown }) : null
|
|
821
|
+
if (current_owner?.token === token) {
|
|
822
|
+
fs.unlinkSync(slot_file)
|
|
823
|
+
}
|
|
824
|
+
} catch {}
|
|
825
|
+
},
|
|
826
|
+
}
|
|
827
|
+
} catch (error) {
|
|
828
|
+
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
829
|
+
throw error
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
|
|
834
|
+
const current_owner = raw ? (JSON.parse(raw) as { pid?: unknown }) : null
|
|
835
|
+
const current_pid = typeof current_owner?.pid === 'number' ? current_owner.pid : null
|
|
836
|
+
if (current_pid != null) {
|
|
837
|
+
try {
|
|
838
|
+
process.kill(current_pid, 0)
|
|
839
|
+
continue
|
|
840
|
+
} catch {}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const slot_age_ms = Date.now() - fs.statSync(slot_file).mtimeMs
|
|
844
|
+
if (current_pid != null || slot_age_ms >= MULTIPROCESS_STALE_LOCK_MS) {
|
|
845
|
+
fs.unlinkSync(slot_file)
|
|
846
|
+
}
|
|
847
|
+
} catch {}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const sleep_ms = Math.min(retry_delay_ms, remaining_ms ?? retry_delay_ms)
|
|
852
|
+
if (sleep_ms > 0) {
|
|
853
|
+
sleepSync(sleep_ms)
|
|
854
|
+
}
|
|
855
|
+
retry_delay_ms = Math.min(retry_delay_ms * 2, 1000)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (!semaphore_lax) {
|
|
859
|
+
throw new SemaphoreTimeoutError(
|
|
860
|
+
`Failed to acquire semaphore "${scoped_key}" within ${semaphore_timeout_seconds}s (limit=${semaphore_limit})`,
|
|
861
|
+
{ semaphore_name: scoped_key, semaphore_limit, timeout_seconds: semaphore_timeout_seconds ?? 0 }
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return null
|
|
866
|
+
}
|
|
867
|
+
|
|
540
868
|
/** Run fn() with a timeout. Rejects with RetryTimeoutError if the timeout fires first. */
|
|
541
869
|
async function _runWithTimeout<T>(fn: () => Promise<T>, timeout_ms: number, attempt: number): Promise<T> {
|
|
542
870
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -576,3 +904,21 @@ async function _runWithTimeout<T>(fn: () => Promise<T>, timeout_ms: number, atte
|
|
|
576
904
|
function sleep(ms: number): Promise<void> {
|
|
577
905
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
578
906
|
}
|
|
907
|
+
|
|
908
|
+
function sleepSync(ms: number): void {
|
|
909
|
+
if (ms <= 0) return
|
|
910
|
+
|
|
911
|
+
const shared_array_buffer = (globalThis as { SharedArrayBuffer?: typeof SharedArrayBuffer }).SharedArrayBuffer
|
|
912
|
+
const atomics = (globalThis as { Atomics?: typeof Atomics }).Atomics
|
|
913
|
+
if (typeof shared_array_buffer === 'function' && typeof atomics?.wait === 'function') {
|
|
914
|
+
try {
|
|
915
|
+
const buffer = new shared_array_buffer(4)
|
|
916
|
+
const view = new Int32Array(buffer)
|
|
917
|
+
atomics.wait(view, 0, 0, ms)
|
|
918
|
+
return
|
|
919
|
+
} catch {}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const deadline = Date.now() + ms
|
|
923
|
+
while (Date.now() < deadline) {}
|
|
924
|
+
}
|