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/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,65 @@ 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 (
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
- // 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
- }
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
- 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
- }
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
+ }