abxbus 2.5.1 → 2.5.3

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/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
- async acquire(): Promise<void> {
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 any async function):
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
- // Usage as a TC39 Stage 3 decorator on class methods (TS 5.0+):
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,60 @@ export function retry(options: RetryOptions = {}) {
222
245
  semaphore_timeout,
223
246
  } = options
224
247
 
225
- return function decorator<T extends (...args: any[]) => any>(target: T, _context?: ClassMethodDecoratorContext): T {
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
- async function retryWrapper(this: any, ...args: any[]): Promise<any> {
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 (this_arg: any, args: any[], first_thenable: PromiseLike<any>, first_attempt: number): Promise<any> => {
279
+ let current_result: any = first_thenable
280
+ for (let attempt = first_attempt; attempt <= effective_max_attempts; attempt++) {
281
+ try {
282
+ if (attempt !== first_attempt) {
283
+ current_result = target.apply(this_arg, args)
284
+ }
285
+ if (isThenable(current_result)) {
286
+ if (timeout != null && timeout > 0) {
287
+ return await _runWithTimeout(() => Promise.resolve(current_result), timeout * 1000, attempt)
288
+ }
289
+ return await current_result
290
+ }
291
+ return current_result
292
+ } catch (error) {
293
+ if (!shouldRetry(error)) throw error
294
+ if (attempt >= effective_max_attempts) throw error
295
+ await asyncRetryDelay(attempt)
296
+ }
297
+ }
298
+ throw new Error(`retry(${fn_name}): unexpected end of retry loop`)
299
+ }
300
+
301
+ async function asyncRetryWrapper(this: any, ...args: any[]): Promise<any> {
231
302
  const base_name = typeof semaphore_name_option === 'function' ? semaphore_name_option(...args) : (semaphore_name_option ?? fn_name)
232
303
  const sem_name = typeof base_name === 'string' ? base_name : String(base_name)
233
304
  // ── Resolve scoped semaphore key at call time (uses `this` for class/instance scopes) ──
@@ -305,26 +376,13 @@ export function retry(options: RetryOptions = {}) {
305
376
  return await Promise.resolve(target.apply(this, args))
306
377
  }
307
378
  } catch (error) {
308
- // Check if this error type should trigger a retry
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
- }
379
+ if (!shouldRetry(error)) throw error
319
380
 
320
381
  // Last attempt: rethrow
321
382
  if (attempt >= effective_max_attempts) throw error
322
383
 
323
384
  // Wait before next attempt with exponential backoff
324
- const delay_seconds = effective_retry_after * Math.pow(retry_backoff_factor, attempt - 1)
325
- if (delay_seconds > 0) {
326
- await sleep(delay_seconds * 1000)
327
- }
385
+ await asyncRetryDelay(attempt)
328
386
  }
329
387
  }
330
388
 
@@ -343,6 +401,89 @@ export function retry(options: RetryOptions = {}) {
343
401
  }
344
402
  }
345
403
 
404
+ function syncRetryWrapper(this: any, ...args: any[]): any {
405
+ const base_name = typeof semaphore_name_option === 'function' ? semaphore_name_option(...args) : (semaphore_name_option ?? fn_name)
406
+ const sem_name = typeof base_name === 'string' ? base_name : String(base_name)
407
+ const scoped_key = scopedSemaphoreKey(sem_name, semaphore_scope, this)
408
+
409
+ const held = getHeldSemaphores()
410
+ const needs_semaphore = semaphore_limit != null && semaphore_limit > 0
411
+ const is_reentrant = needs_semaphore && held.has(scoped_key)
412
+
413
+ let semaphore: RetrySemaphore | null = null
414
+ let multiprocess_lock: SyncMultiprocessLockHandle | null = null
415
+ let semaphore_acquired = false
416
+
417
+ if (needs_semaphore && !is_reentrant) {
418
+ const effective_sem_timeout =
419
+ semaphore_timeout != null ? semaphore_timeout : timeout != null ? timeout * Math.max(1, semaphore_limit! - 1) : null
420
+
421
+ if (semaphore_scope === 'multiprocess') {
422
+ if (isNodeRuntime()) {
423
+ multiprocess_lock = acquireMultiprocessSemaphoreSync(scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
424
+ semaphore_acquired = multiprocess_lock !== null
425
+ } else {
426
+ logMultiprocessFallbackOnce('multiprocess semaphores require a Node.js runtime; falling back to in-process global scope')
427
+ semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
428
+ semaphore_acquired = acquireSemaphoreSyncOrThrow(semaphore, scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
429
+ }
430
+ } else {
431
+ semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
432
+ semaphore_acquired = acquireSemaphoreSyncOrThrow(semaphore, scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
433
+ }
434
+ }
435
+
436
+ const new_held = new Set(held)
437
+ if (semaphore_acquired) {
438
+ new_held.add(scoped_key)
439
+ }
440
+
441
+ const release = (): void => {
442
+ if (semaphore_acquired && multiprocess_lock) {
443
+ multiprocess_lock.release()
444
+ } else if (semaphore_acquired && semaphore) {
445
+ semaphore.release()
446
+ }
447
+ }
448
+
449
+ const runRetryLoop = (): any => {
450
+ for (let attempt = 1; attempt <= effective_max_attempts; attempt++) {
451
+ const attempt_started_at = Date.now()
452
+ try {
453
+ const result = target.apply(this, args)
454
+ if (isThenable(result)) {
455
+ return runRetryLoopFromThenable(this, args, result, attempt)
456
+ }
457
+ if (timeout != null && timeout > 0 && Date.now() - attempt_started_at > timeout * 1000) {
458
+ throw new RetryTimeoutError(`Timed out after ${timeout}s (attempt ${attempt})`, {
459
+ timeout_seconds: timeout,
460
+ attempt,
461
+ })
462
+ }
463
+ return result
464
+ } catch (error) {
465
+ if (!shouldRetry(error)) throw error
466
+ if (attempt >= effective_max_attempts) throw error
467
+ syncRetryDelay(attempt)
468
+ }
469
+ }
470
+ throw new Error(`retry(${fn_name}): unexpected end of retry loop`)
471
+ }
472
+
473
+ try {
474
+ const result = runWithHeldSemaphores(new_held, runRetryLoop)
475
+ if (isThenable(result)) {
476
+ return Promise.resolve(result).finally(release)
477
+ }
478
+ release()
479
+ return result
480
+ } catch (error) {
481
+ release()
482
+ throw error
483
+ }
484
+ }
485
+
486
+ const retryWrapper = isAsyncFunction(target) ? asyncRetryWrapper : syncRetryWrapper
346
487
  Object.defineProperty(retryWrapper, 'name', { value: fn_name, configurable: true })
347
488
  if (_context?.kind === 'method' && typeof _context.addInitializer === 'function') {
348
489
  _context.addInitializer(function (this: unknown) {
@@ -354,6 +495,22 @@ export function retry(options: RetryOptions = {}) {
354
495
  }
355
496
  return retryWrapper as unknown as T
356
497
  }
498
+
499
+ function decorator<T extends AnyFunction>(
500
+ target: T | object,
501
+ context_or_property_key?: ClassMethodDecoratorContext | string | symbol,
502
+ descriptor?: LegacyMethodDescriptor
503
+ ): T | LegacyMethodDescriptor {
504
+ if (descriptor?.value && typeof descriptor.value === 'function') {
505
+ descriptor.value = decorateFunction(descriptor.value)
506
+ return descriptor
507
+ }
508
+ if (typeof target === 'function') {
509
+ return decorateFunction(target as T, typeof context_or_property_key === 'object' ? context_or_property_key : undefined)
510
+ }
511
+ throw new TypeError('retry() can only decorate functions and class methods')
512
+ }
513
+ return decorator as RetryDecorator
357
514
  }
358
515
 
359
516
  // ─── Internal helpers ────────────────────────────────────────────────────────
@@ -397,6 +554,18 @@ function findDecoratedMethodOwnerName(
397
554
  return null
398
555
  }
399
556
 
557
+ function isAsyncFunction(fn: AnyFunction): boolean {
558
+ return Object.prototype.toString.call(fn) === '[object AsyncFunction]'
559
+ }
560
+
561
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
562
+ return (
563
+ (typeof value === 'object' || typeof value === 'function') &&
564
+ value !== null &&
565
+ typeof (value as { then?: unknown }).then === 'function'
566
+ )
567
+ }
568
+
400
569
  /**
401
570
  * Try to acquire a semaphore within a timeout. Returns true if acquired, false if timed out.
402
571
  * If the semaphore is acquired after the timeout (due to the waiter remaining queued),
@@ -426,12 +595,70 @@ async function acquireWithTimeout(semaphore: RetrySemaphore, timeout_ms: number)
426
595
  })
427
596
  }
428
597
 
598
+ function acquireSemaphoreSyncOrThrow(
599
+ semaphore: RetrySemaphore,
600
+ scoped_key: string,
601
+ semaphore_limit: number,
602
+ timeout_seconds: number | null,
603
+ semaphore_lax: boolean
604
+ ): boolean {
605
+ const acquired = acquireSemaphoreSync(semaphore, timeout_seconds == null ? null : timeout_seconds * 1000)
606
+ if (acquired) return true
607
+
608
+ if (!semaphore_lax) {
609
+ throw new SemaphoreTimeoutError(`Failed to acquire semaphore "${scoped_key}" within ${timeout_seconds}s (limit=${semaphore_limit})`, {
610
+ semaphore_name: scoped_key,
611
+ semaphore_limit,
612
+ timeout_seconds: timeout_seconds ?? 0,
613
+ })
614
+ }
615
+ return false
616
+ }
617
+
618
+ function acquireSemaphoreSync(semaphore: RetrySemaphore, timeout_ms: number | null): boolean {
619
+ if (semaphore.tryAcquire()) return true
620
+
621
+ const start = Date.now()
622
+ while (true) {
623
+ if (timeout_ms != null && timeout_ms > 0 && Date.now() - start >= timeout_ms) {
624
+ return false
625
+ }
626
+ sleepSync(10)
627
+ if (semaphore.tryAcquire()) return true
628
+ }
629
+ }
630
+
429
631
  function logMultiprocessFallbackOnce(reason: string): void {
430
632
  if (multiprocess_fallback_reason_logged === reason) return
431
633
  multiprocess_fallback_reason_logged = reason
432
634
  console.warn(`[abxbus.retry] ${reason}`)
433
635
  }
434
636
 
637
+ function importNodeModuleSync(specifier: string): any {
638
+ const maybe_process = (
639
+ globalThis as {
640
+ process?: { getBuiltinModule?: (name: string) => any }
641
+ }
642
+ ).process
643
+ const get_builtin_module = maybe_process?.getBuiltinModule
644
+ const bare_specifier = specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier
645
+
646
+ if (typeof get_builtin_module === 'function') {
647
+ const builtin = get_builtin_module(bare_specifier) ?? get_builtin_module(specifier)
648
+ if (builtin) return builtin
649
+ }
650
+
651
+ let require_fn: ((name: string) => any) | undefined
652
+ try {
653
+ require_fn = Function('return typeof require === "function" ? require : undefined')() as ((name: string) => any) | undefined
654
+ } catch {
655
+ require_fn = undefined
656
+ }
657
+ if (require_fn) return require_fn(specifier)
658
+
659
+ throw new Error('[abxbus.retry] synchronous Node.js module loading is unavailable; cannot use sync multiprocess semaphores')
660
+ }
661
+
435
662
  async function importNodeModule(specifier: string): Promise<any> {
436
663
  const dynamic_import = Function('module_name', 'return import(module_name)') as (module_name: string) => Promise<unknown>
437
664
  return dynamic_import(specifier) as Promise<any>
@@ -537,6 +764,104 @@ async function acquireMultiprocessSemaphore(
537
764
  return null
538
765
  }
539
766
 
767
+ function acquireMultiprocessSemaphoreSync(
768
+ scoped_key: string,
769
+ semaphore_limit: number,
770
+ semaphore_timeout_seconds: number | null,
771
+ semaphore_lax: boolean
772
+ ): SyncMultiprocessLockHandle | null {
773
+ const crypto = importNodeModuleSync('node:crypto')
774
+ const fs = importNodeModuleSync('node:fs')
775
+ const os = importNodeModuleSync('node:os')
776
+ const path = importNodeModuleSync('node:path')
777
+ const semaphore_directory = path.join(os.tmpdir(), MULTIPROCESS_SEMAPHORE_DIRNAME)
778
+ const lock_prefix = crypto.createHash('sha256').update(scoped_key).digest('hex').slice(0, 40)
779
+ fs.mkdirSync(semaphore_directory, { recursive: true })
780
+
781
+ const start = Date.now()
782
+ let retry_delay_ms = 100
783
+
784
+ while (true) {
785
+ const elapsed_ms = Date.now() - start
786
+ const remaining_ms =
787
+ semaphore_timeout_seconds != null && semaphore_timeout_seconds > 0 ? semaphore_timeout_seconds * 1000 - elapsed_ms : null
788
+
789
+ if (remaining_ms != null && remaining_ms <= 0) {
790
+ break
791
+ }
792
+
793
+ for (let slot = 0; slot < semaphore_limit; slot++) {
794
+ const slot_file = path.join(semaphore_directory, `${lock_prefix}.${String(slot).padStart(2, '0')}.lock`)
795
+ const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`
796
+
797
+ try {
798
+ const fd = fs.openSync(slot_file, 'wx', 0o600)
799
+ try {
800
+ fs.writeFileSync(
801
+ fd,
802
+ JSON.stringify({
803
+ token,
804
+ pid: process.pid,
805
+ semaphore_name: scoped_key,
806
+ created_at_ms: Date.now(),
807
+ }),
808
+ 'utf8'
809
+ )
810
+ } finally {
811
+ fs.closeSync(fd)
812
+ }
813
+ return {
814
+ release: () => {
815
+ try {
816
+ const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
817
+ const current_owner = raw ? (JSON.parse(raw) as { token?: unknown }) : null
818
+ if (current_owner?.token === token) {
819
+ fs.unlinkSync(slot_file)
820
+ }
821
+ } catch {}
822
+ },
823
+ }
824
+ } catch (error) {
825
+ if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'EEXIST') {
826
+ throw error
827
+ }
828
+
829
+ try {
830
+ const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
831
+ const current_owner = raw ? (JSON.parse(raw) as { pid?: unknown }) : null
832
+ const current_pid = typeof current_owner?.pid === 'number' ? current_owner.pid : null
833
+ if (current_pid != null) {
834
+ try {
835
+ process.kill(current_pid, 0)
836
+ continue
837
+ } catch {}
838
+ }
839
+
840
+ const slot_age_ms = Date.now() - fs.statSync(slot_file).mtimeMs
841
+ if (current_pid != null || slot_age_ms >= MULTIPROCESS_STALE_LOCK_MS) {
842
+ fs.unlinkSync(slot_file)
843
+ }
844
+ } catch {}
845
+ }
846
+ }
847
+
848
+ const sleep_ms = Math.min(retry_delay_ms, remaining_ms ?? retry_delay_ms)
849
+ if (sleep_ms > 0) {
850
+ sleepSync(sleep_ms)
851
+ }
852
+ retry_delay_ms = Math.min(retry_delay_ms * 2, 1000)
853
+ }
854
+
855
+ if (!semaphore_lax) {
856
+ throw new SemaphoreTimeoutError(
857
+ `Failed to acquire semaphore "${scoped_key}" within ${semaphore_timeout_seconds}s (limit=${semaphore_limit})`,
858
+ { semaphore_name: scoped_key, semaphore_limit, timeout_seconds: semaphore_timeout_seconds ?? 0 }
859
+ )
860
+ }
861
+
862
+ return null
863
+ }
864
+
540
865
  /** Run fn() with a timeout. Rejects with RetryTimeoutError if the timeout fires first. */
541
866
  async function _runWithTimeout<T>(fn: () => Promise<T>, timeout_ms: number, attempt: number): Promise<T> {
542
867
  return new Promise<T>((resolve, reject) => {
@@ -576,3 +901,21 @@ async function _runWithTimeout<T>(fn: () => Promise<T>, timeout_ms: number, atte
576
901
  function sleep(ms: number): Promise<void> {
577
902
  return new Promise((resolve) => setTimeout(resolve, ms))
578
903
  }
904
+
905
+ function sleepSync(ms: number): void {
906
+ if (ms <= 0) return
907
+
908
+ const shared_array_buffer = (globalThis as { SharedArrayBuffer?: typeof SharedArrayBuffer }).SharedArrayBuffer
909
+ const atomics = (globalThis as { Atomics?: typeof Atomics }).Atomics
910
+ if (typeof shared_array_buffer === 'function' && typeof atomics?.wait === 'function') {
911
+ try {
912
+ const buffer = new shared_array_buffer(4)
913
+ const view = new Int32Array(buffer)
914
+ atomics.wait(view, 0, 0, ms)
915
+ return
916
+ } catch {}
917
+ }
918
+
919
+ const deadline = Date.now() + ms
920
+ while (Date.now() < deadline) {}
921
+ }