abxbus 2.4.16 → 2.4.18

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.
Files changed (42) hide show
  1. package/dist/cjs/event_bus.js +1 -1
  2. package/dist/cjs/event_bus.js.map +2 -2
  3. package/dist/cjs/event_handler.d.ts +1 -0
  4. package/dist/cjs/event_handler.js +14 -1
  5. package/dist/cjs/event_handler.js.map +2 -2
  6. package/dist/cjs/middleware_otel_tracing.js +3 -3
  7. package/dist/cjs/middleware_otel_tracing.js.map +2 -2
  8. package/dist/cjs/retry.js +39 -0
  9. package/dist/cjs/retry.js.map +2 -2
  10. package/dist/esm/event_bus.js +1 -1
  11. package/dist/esm/event_bus.js.map +2 -2
  12. package/dist/esm/event_handler.js +14 -1
  13. package/dist/esm/event_handler.js.map +2 -2
  14. package/dist/esm/middleware_otel_tracing.js +4 -4
  15. package/dist/esm/middleware_otel_tracing.js.map +2 -2
  16. package/dist/esm/retry.js +39 -0
  17. package/dist/esm/retry.js.map +2 -2
  18. package/dist/types/event_handler.d.ts +1 -0
  19. package/package.json +5 -1
  20. package/src/async_context.ts +70 -0
  21. package/src/base_event.ts +1201 -0
  22. package/src/bridge_jsonl.ts +174 -0
  23. package/src/bridge_nats.ts +104 -0
  24. package/src/bridge_postgres.ts +277 -0
  25. package/src/bridge_redis.ts +194 -0
  26. package/src/bridge_sqlite.ts +289 -0
  27. package/src/bridges.ts +376 -0
  28. package/src/event_bus.ts +1263 -0
  29. package/src/event_handler.ts +379 -0
  30. package/src/event_history.ts +247 -0
  31. package/src/event_result.ts +483 -0
  32. package/src/events_suck.ts +96 -0
  33. package/src/helpers.ts +65 -0
  34. package/src/index.ts +37 -0
  35. package/src/lock_manager.ts +401 -0
  36. package/src/logging.ts +261 -0
  37. package/src/middleware_otel_tracing.ts +201 -0
  38. package/src/middlewares.ts +16 -0
  39. package/src/optional_deps.ts +52 -0
  40. package/src/retry.ts +578 -0
  41. package/src/timing.ts +52 -0
  42. package/src/types.ts +132 -0
package/src/retry.ts ADDED
@@ -0,0 +1,578 @@
1
+ import { createAsyncLocalStorage, type AsyncLocalStorageLike } from './async_context.js'
2
+ import { isNodeRuntime } from './optional_deps.js'
3
+
4
+ type SemaphoreScope = 'multiprocess' | 'global' | 'class' | 'instance'
5
+
6
+ type MultiprocessLockHandle = {
7
+ release: () => Promise<void>
8
+ }
9
+
10
+ const MULTIPROCESS_SEMAPHORE_DIRNAME = 'browser_use_semaphores'
11
+ const MULTIPROCESS_STALE_LOCK_MS = 5 * 60 * 1000
12
+
13
+ let multiprocess_fallback_reason_logged: string | null = null
14
+
15
+ // ─── Types ───────────────────────────────────────────────────────────────────
16
+
17
+ export interface RetryOptions {
18
+ /** Total number of attempts including the initial call (1 = no retry, 3 = up to 2 retries). Default: 1 */
19
+ max_attempts?: number
20
+
21
+ /** Seconds to wait between retries. Default: 0 */
22
+ retry_after?: number
23
+
24
+ /** Multiplier applied to retry_after after each attempt for exponential backoff. Default: 1.0 (constant delay) */
25
+ retry_backoff_factor?: number
26
+
27
+ /** Only retry when the thrown error matches one of these matchers. Accepts error class constructors,
28
+ * string error names (matched against error.name), or RegExp patterns (tested against String(error)).
29
+ * Default: undefined (retry on any error) */
30
+ retry_on_errors?: Array<(new (...args: any[]) => Error) | string | RegExp>
31
+
32
+ /** Per-attempt timeout in seconds. Default: undefined (no per-attempt timeout) */
33
+ timeout?: number | null
34
+
35
+ /** Maximum concurrent executions sharing this semaphore. Default: undefined (no concurrency limit) */
36
+ semaphore_limit?: number | null
37
+
38
+ /** Semaphore identifier. Functions with the same name share the same concurrency slot pool. Default: function name.
39
+ * If a function is provided, it receives the same arguments as the wrapped function. */
40
+ semaphore_name?: string | ((...args: any[]) => string) | null
41
+
42
+ /** If true, proceed without concurrency limit when semaphore acquisition times out. Default: true */
43
+ semaphore_lax?: boolean
44
+
45
+ /** Semaphore scoping strategy. Default: 'global'
46
+ * - 'multiprocess': all processes on the machine share one semaphore (Node.js only)
47
+ * - 'global': all calls share one semaphore (keyed by semaphore_name)
48
+ * - 'class': all instances of the same class share one semaphore (keyed by className.semaphore_name)
49
+ * - 'instance': each object instance gets its own semaphore (keyed by instanceId.semaphore_name)
50
+ * 'class' and 'instance' require `this` to be an object; they fall back to 'global' for standalone calls. */
51
+ semaphore_scope?: SemaphoreScope
52
+
53
+ /** Maximum seconds to wait for semaphore acquisition. Default: undefined → timeout * max(1, limit - 1) */
54
+ semaphore_timeout?: number | null
55
+ }
56
+
57
+ // ─── Errors ──────────────────────────────────────────────────────────────────
58
+
59
+ /** Thrown when a single attempt exceeds the per-attempt timeout. */
60
+ export class RetryTimeoutError extends Error {
61
+ timeout_seconds: number
62
+ attempt: number
63
+
64
+ constructor(message: string, params: { timeout_seconds: number; attempt: number }) {
65
+ super(message)
66
+ this.name = 'RetryTimeoutError'
67
+ this.timeout_seconds = params.timeout_seconds
68
+ this.attempt = params.attempt
69
+ }
70
+ }
71
+
72
+ /** Thrown (when semaphore_lax=false) if the semaphore cannot be acquired within the timeout. */
73
+ export class SemaphoreTimeoutError extends Error {
74
+ semaphore_name: string
75
+ semaphore_limit: number
76
+ timeout_seconds: number
77
+
78
+ constructor(message: string, params: { semaphore_name: string; semaphore_limit: number; timeout_seconds: number }) {
79
+ super(message)
80
+ this.name = 'SemaphoreTimeoutError'
81
+ this.semaphore_name = params.semaphore_name
82
+ this.semaphore_limit = params.semaphore_limit
83
+ this.timeout_seconds = params.timeout_seconds
84
+ }
85
+ }
86
+
87
+ // ─── Re-entrancy tracking via AsyncLocalStorage ──────────────────────────────
88
+ //
89
+ // Prevents deadlocks when a retry()-wrapped function calls another retry()-wrapped
90
+ // function that shares the same semaphore (or calls itself recursively).
91
+ //
92
+ // Each async call stack tracks which semaphore names it currently holds. When a
93
+ // nested call encounters a semaphore it already holds, it skips acquisition and
94
+ // runs directly within the parent's slot.
95
+ //
96
+ // Uses the same AsyncLocalStorage polyfill as the rest of abxbus (see async_context.ts)
97
+ // so it works in Node.js and gracefully degrades to a no-op in browsers.
98
+
99
+ type ReentrantStore = Set<string>
100
+
101
+ // Separate AsyncLocalStorage instance for retry re-entrancy tracking.
102
+ // Created via the shared factory in async_context.ts (returns null in browsers).
103
+ const retry_context_storage: AsyncLocalStorageLike | null = createAsyncLocalStorage()
104
+
105
+ function getHeldSemaphores(): ReentrantStore {
106
+ return (retry_context_storage?.getStore() as ReentrantStore | undefined) ?? new Set()
107
+ }
108
+
109
+ function runWithHeldSemaphores<T>(held: ReentrantStore, fn: () => T): T {
110
+ if (!retry_context_storage) return fn()
111
+ return retry_context_storage.run(held, fn)
112
+ }
113
+
114
+ // ─── Semaphore scope helpers ─────────────────────────────────────────────────
115
+
116
+ let _next_instance_id = 1
117
+ const _instance_ids = new WeakMap<object, number>()
118
+
119
+ function scopedSemaphoreKey(base_name: string, scope: SemaphoreScope, context: unknown): string {
120
+ if (scope === 'class' && context && typeof context === 'object') {
121
+ return `${(context as object).constructor?.name ?? 'Object'}.${base_name}`
122
+ }
123
+ if (scope === 'instance' && context && typeof context === 'object') {
124
+ let id = _instance_ids.get(context as object)
125
+ if (id === undefined) {
126
+ id = _next_instance_id++
127
+ _instance_ids.set(context as object, id)
128
+ }
129
+ return `${id}.${base_name}`
130
+ }
131
+ return base_name
132
+ }
133
+
134
+ // ─── Global semaphore registry ───────────────────────────────────────────────
135
+
136
+ class RetrySemaphore {
137
+ readonly size: number
138
+ private inUse: number
139
+ private waiters: Array<() => void>
140
+
141
+ constructor(size: number) {
142
+ this.size = size
143
+ this.inUse = 0
144
+ this.waiters = []
145
+ }
146
+
147
+ async acquire(): Promise<void> {
148
+ if (this.size === Infinity) {
149
+ return
150
+ }
151
+ if (this.inUse < this.size) {
152
+ this.inUse += 1
153
+ return
154
+ }
155
+ await new Promise<void>((resolve) => {
156
+ this.waiters.push(resolve)
157
+ })
158
+ }
159
+
160
+ release(): void {
161
+ if (this.size === Infinity) {
162
+ return
163
+ }
164
+ const next = this.waiters.shift()
165
+ if (next) {
166
+ // Handoff: keep the permit accounted for and transfer it directly to the waiter.
167
+ next()
168
+ return
169
+ }
170
+ this.inUse = Math.max(0, this.inUse - 1)
171
+ }
172
+ }
173
+
174
+ const SEMAPHORE_REGISTRY = new Map<string, RetrySemaphore>()
175
+
176
+ function getOrCreateSemaphore(name: string, limit: number): RetrySemaphore {
177
+ const existing = SEMAPHORE_REGISTRY.get(name)
178
+ if (existing && existing.size === limit) return existing
179
+ const sem = new RetrySemaphore(limit)
180
+ SEMAPHORE_REGISTRY.set(name, sem)
181
+ return sem
182
+ }
183
+
184
+ /** Reset the global semaphore registry. Useful in tests. */
185
+ export function clearSemaphoreRegistry(): void {
186
+ SEMAPHORE_REGISTRY.clear()
187
+ multiprocess_fallback_reason_logged = null
188
+ }
189
+
190
+ // ─── retry() decorator / higher-order wrapper ────────────────────────────────
191
+ //
192
+ // Usage as a higher-order function (works on any async function):
193
+ //
194
+ // const fetchWithRetry = retry({ max_attempts: 3, retry_after: 1 })(async (url: string) => {
195
+ // return await fetch(url)
196
+ // })
197
+ //
198
+ // Usage as a TC39 Stage 3 decorator on class methods (TS 5.0+):
199
+ //
200
+ // class ApiClient {
201
+ // @retry({ max_attempts: 3, retry_after: 1 })
202
+ // async fetchData(): Promise<Data> { ... }
203
+ // }
204
+ //
205
+ // Usage on event bus handlers:
206
+ //
207
+ // bus.on(MyEvent, retry({ max_attempts: 3 })(async (event) => {
208
+ // await riskyOperation(event.data)
209
+ // }))
210
+
211
+ export function retry(options: RetryOptions = {}) {
212
+ const {
213
+ max_attempts = 1,
214
+ retry_after = 0,
215
+ retry_backoff_factor = 1.0,
216
+ retry_on_errors,
217
+ timeout,
218
+ semaphore_limit,
219
+ semaphore_name: semaphore_name_option,
220
+ semaphore_lax = true,
221
+ semaphore_scope = 'global',
222
+ semaphore_timeout,
223
+ } = options
224
+
225
+ return function decorator<T extends (...args: any[]) => any>(target: T, _context?: ClassMethodDecoratorContext): T {
226
+ const fn_name = target.name || (_context?.name as string) || 'anonymous'
227
+ const effective_max_attempts = Math.max(1, max_attempts)
228
+ const effective_retry_after = Math.max(0, retry_after)
229
+
230
+ async function retryWrapper(this: any, ...args: any[]): Promise<any> {
231
+ const base_name = typeof semaphore_name_option === 'function' ? semaphore_name_option(...args) : (semaphore_name_option ?? fn_name)
232
+ const sem_name = typeof base_name === 'string' ? base_name : String(base_name)
233
+ // ── Resolve scoped semaphore key at call time (uses `this` for class/instance scopes) ──
234
+ const scoped_key = scopedSemaphoreKey(sem_name, semaphore_scope, this)
235
+
236
+ // ── Check re-entrancy: skip semaphore if we already hold it in this async context ──
237
+ const held = getHeldSemaphores()
238
+ const needs_semaphore = semaphore_limit != null && semaphore_limit > 0
239
+ const is_reentrant = needs_semaphore && held.has(scoped_key)
240
+
241
+ // ── Semaphore acquisition (held across all retry attempts, skipped if re-entrant) ──
242
+ let semaphore: RetrySemaphore | null = null
243
+ let multiprocess_lock: MultiprocessLockHandle | null = null
244
+ let semaphore_acquired = false
245
+
246
+ if (needs_semaphore && !is_reentrant) {
247
+ const effective_sem_timeout =
248
+ semaphore_timeout != null ? semaphore_timeout : timeout != null ? timeout * Math.max(1, semaphore_limit! - 1) : null
249
+
250
+ if (semaphore_scope === 'multiprocess') {
251
+ if (isNodeRuntime()) {
252
+ multiprocess_lock = await acquireMultiprocessSemaphore(scoped_key, semaphore_limit!, effective_sem_timeout, semaphore_lax)
253
+ semaphore_acquired = multiprocess_lock !== null
254
+ } else {
255
+ logMultiprocessFallbackOnce('multiprocess semaphores require a Node.js runtime; falling back to in-process global scope')
256
+ semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
257
+ if (effective_sem_timeout != null && effective_sem_timeout > 0) {
258
+ semaphore_acquired = await acquireWithTimeout(semaphore, effective_sem_timeout * 1000)
259
+ if (!semaphore_acquired) {
260
+ if (!semaphore_lax) {
261
+ throw new SemaphoreTimeoutError(
262
+ `Failed to acquire semaphore "${scoped_key}" within ${effective_sem_timeout}s (limit=${semaphore_limit})`,
263
+ { semaphore_name: scoped_key, semaphore_limit: semaphore_limit!, timeout_seconds: effective_sem_timeout }
264
+ )
265
+ }
266
+ }
267
+ } else {
268
+ await semaphore.acquire()
269
+ semaphore_acquired = true
270
+ }
271
+ }
272
+ } else {
273
+ semaphore = getOrCreateSemaphore(scoped_key, semaphore_limit!)
274
+
275
+ if (effective_sem_timeout != null && effective_sem_timeout > 0) {
276
+ semaphore_acquired = await acquireWithTimeout(semaphore, effective_sem_timeout * 1000)
277
+ if (!semaphore_acquired) {
278
+ if (!semaphore_lax) {
279
+ throw new SemaphoreTimeoutError(
280
+ `Failed to acquire semaphore "${scoped_key}" within ${effective_sem_timeout}s (limit=${semaphore_limit})`,
281
+ { semaphore_name: scoped_key, semaphore_limit: semaphore_limit!, timeout_seconds: effective_sem_timeout }
282
+ )
283
+ }
284
+ }
285
+ } else {
286
+ await semaphore.acquire()
287
+ semaphore_acquired = true
288
+ }
289
+ }
290
+ }
291
+
292
+ // ── Build the set of held semaphores for nested calls ──
293
+ const new_held = new Set(held)
294
+ if (semaphore_acquired) {
295
+ new_held.add(scoped_key)
296
+ }
297
+
298
+ // ── Retry loop (runs inside the semaphore and re-entrancy context) ──
299
+ const runRetryLoop = async (): Promise<any> => {
300
+ for (let attempt = 1; attempt <= effective_max_attempts; attempt++) {
301
+ try {
302
+ if (timeout != null && timeout > 0) {
303
+ return await _runWithTimeout(() => Promise.resolve(target.apply(this, args)), timeout * 1000, attempt)
304
+ } else {
305
+ return await Promise.resolve(target.apply(this, args))
306
+ }
307
+ } 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
+ }
319
+
320
+ // Last attempt: rethrow
321
+ if (attempt >= effective_max_attempts) throw error
322
+
323
+ // 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
+ }
328
+ }
329
+ }
330
+
331
+ // Unreachable, but satisfies the type checker
332
+ throw new Error(`retry(${fn_name}): unexpected end of retry loop`)
333
+ }
334
+
335
+ try {
336
+ return await runWithHeldSemaphores(new_held, runRetryLoop)
337
+ } finally {
338
+ if (semaphore_acquired && multiprocess_lock) {
339
+ await multiprocess_lock.release()
340
+ } else if (semaphore_acquired && semaphore) {
341
+ semaphore.release()
342
+ }
343
+ }
344
+ }
345
+
346
+ Object.defineProperty(retryWrapper, 'name', { value: fn_name, configurable: true })
347
+ if (_context?.kind === 'method' && typeof _context.addInitializer === 'function') {
348
+ _context.addInitializer(function (this: unknown) {
349
+ const owner_name = findDecoratedMethodOwnerName(this, _context, retryWrapper)
350
+ if (owner_name) {
351
+ Object.defineProperty(retryWrapper, 'name', { value: `${owner_name}.${fn_name}`, configurable: true })
352
+ }
353
+ })
354
+ }
355
+ return retryWrapper as unknown as T
356
+ }
357
+ }
358
+
359
+ // ─── Internal helpers ────────────────────────────────────────────────────────
360
+
361
+ function findDecoratedMethodOwnerName(
362
+ context_this: unknown,
363
+ context: ClassMethodDecoratorContext,
364
+ replacement: (...args: any[]) => any
365
+ ): string | null {
366
+ const method_name = context.name
367
+ if (typeof method_name !== 'string') {
368
+ return null
369
+ }
370
+
371
+ if (context.static) {
372
+ let ctor = typeof context_this === 'function' ? context_this : null
373
+ while (ctor && ctor !== Function.prototype) {
374
+ const descriptor = Object.getOwnPropertyDescriptor(ctor, method_name)
375
+ if (descriptor?.value === replacement) {
376
+ return ctor.name || null
377
+ }
378
+ const parent = Object.getPrototypeOf(ctor)
379
+ ctor = typeof parent === 'function' ? parent : null
380
+ }
381
+ return null
382
+ }
383
+
384
+ if ((typeof context_this !== 'object' && typeof context_this !== 'function') || context_this === null) {
385
+ return null
386
+ }
387
+
388
+ let prototype = Object.getPrototypeOf(context_this)
389
+ while (prototype && prototype !== Object.prototype) {
390
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, method_name)
391
+ if (descriptor?.value === replacement) {
392
+ const ctor_name = (prototype as { constructor?: { name?: string } }).constructor?.name
393
+ return ctor_name || null
394
+ }
395
+ prototype = Object.getPrototypeOf(prototype)
396
+ }
397
+ return null
398
+ }
399
+
400
+ /**
401
+ * Try to acquire a semaphore within a timeout. Returns true if acquired, false if timed out.
402
+ * If the semaphore is acquired after the timeout (due to the waiter remaining queued),
403
+ * it is immediately released to avoid leaking slots.
404
+ */
405
+ async function acquireWithTimeout(semaphore: RetrySemaphore, timeout_ms: number): Promise<boolean> {
406
+ return new Promise<boolean>((resolve) => {
407
+ let settled = false
408
+
409
+ const timer = setTimeout(() => {
410
+ if (!settled) {
411
+ settled = true
412
+ resolve(false)
413
+ }
414
+ }, timeout_ms)
415
+
416
+ semaphore.acquire().then(() => {
417
+ if (!settled) {
418
+ settled = true
419
+ clearTimeout(timer)
420
+ resolve(true)
421
+ } else {
422
+ // Acquired after timeout fired — release immediately to avoid slot leak
423
+ semaphore.release()
424
+ }
425
+ })
426
+ })
427
+ }
428
+
429
+ function logMultiprocessFallbackOnce(reason: string): void {
430
+ if (multiprocess_fallback_reason_logged === reason) return
431
+ multiprocess_fallback_reason_logged = reason
432
+ console.warn(`[abxbus.retry] ${reason}`)
433
+ }
434
+
435
+ async function importNodeModule(specifier: string): Promise<any> {
436
+ const dynamic_import = Function('module_name', 'return import(module_name)') as (module_name: string) => Promise<unknown>
437
+ return dynamic_import(specifier) as Promise<any>
438
+ }
439
+
440
+ async function acquireMultiprocessSemaphore(
441
+ scoped_key: string,
442
+ semaphore_limit: number,
443
+ semaphore_timeout_seconds: number | null,
444
+ semaphore_lax: boolean
445
+ ): Promise<MultiprocessLockHandle | null> {
446
+ const [crypto, fs, os, path] = await Promise.all([
447
+ importNodeModule('node:crypto'),
448
+ importNodeModule('node:fs'),
449
+ importNodeModule('node:os'),
450
+ importNodeModule('node:path'),
451
+ ])
452
+ const semaphore_directory = path.join(os.tmpdir(), MULTIPROCESS_SEMAPHORE_DIRNAME)
453
+ const lock_prefix = crypto.createHash('sha256').update(scoped_key).digest('hex').slice(0, 40)
454
+ fs.mkdirSync(semaphore_directory, { recursive: true })
455
+
456
+ const start = Date.now()
457
+ let retry_delay_ms = 100
458
+
459
+ while (true) {
460
+ const elapsed_ms = Date.now() - start
461
+ const remaining_ms =
462
+ semaphore_timeout_seconds != null && semaphore_timeout_seconds > 0 ? semaphore_timeout_seconds * 1000 - elapsed_ms : null
463
+
464
+ if (remaining_ms != null && remaining_ms <= 0) {
465
+ break
466
+ }
467
+
468
+ for (let slot = 0; slot < semaphore_limit; slot++) {
469
+ const slot_file = path.join(semaphore_directory, `${lock_prefix}.${String(slot).padStart(2, '0')}.lock`)
470
+ const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`
471
+
472
+ try {
473
+ const fd = fs.openSync(slot_file, 'wx', 0o600)
474
+ try {
475
+ fs.writeFileSync(
476
+ fd,
477
+ JSON.stringify({
478
+ token,
479
+ pid: process.pid,
480
+ semaphore_name: scoped_key,
481
+ created_at_ms: Date.now(),
482
+ }),
483
+ 'utf8'
484
+ )
485
+ } finally {
486
+ fs.closeSync(fd)
487
+ }
488
+ return {
489
+ release: async () => {
490
+ try {
491
+ const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
492
+ const current_owner = raw ? (JSON.parse(raw) as { token?: unknown }) : null
493
+ if (current_owner?.token === token) {
494
+ fs.unlinkSync(slot_file)
495
+ }
496
+ } catch {}
497
+ },
498
+ }
499
+ } catch (error) {
500
+ if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'EEXIST') {
501
+ throw error
502
+ }
503
+
504
+ try {
505
+ const raw = String(fs.readFileSync(slot_file, 'utf8') || '').trim()
506
+ const current_owner = raw ? (JSON.parse(raw) as { pid?: unknown }) : null
507
+ const current_pid = typeof current_owner?.pid === 'number' ? current_owner.pid : null
508
+ if (current_pid != null) {
509
+ try {
510
+ process.kill(current_pid, 0)
511
+ continue
512
+ } catch {}
513
+ }
514
+
515
+ const slot_age_ms = Date.now() - fs.statSync(slot_file).mtimeMs
516
+ if (current_pid != null || slot_age_ms >= MULTIPROCESS_STALE_LOCK_MS) {
517
+ fs.unlinkSync(slot_file)
518
+ }
519
+ } catch {}
520
+ }
521
+ }
522
+
523
+ const sleep_ms = Math.min(retry_delay_ms, remaining_ms ?? retry_delay_ms)
524
+ if (sleep_ms > 0) {
525
+ await sleep(sleep_ms)
526
+ }
527
+ retry_delay_ms = Math.min(retry_delay_ms * 2, 1000)
528
+ }
529
+
530
+ if (!semaphore_lax) {
531
+ throw new SemaphoreTimeoutError(
532
+ `Failed to acquire semaphore "${scoped_key}" within ${semaphore_timeout_seconds}s (limit=${semaphore_limit})`,
533
+ { semaphore_name: scoped_key, semaphore_limit, timeout_seconds: semaphore_timeout_seconds ?? 0 }
534
+ )
535
+ }
536
+
537
+ return null
538
+ }
539
+
540
+ /** Run fn() with a timeout. Rejects with RetryTimeoutError if the timeout fires first. */
541
+ async function _runWithTimeout<T>(fn: () => Promise<T>, timeout_ms: number, attempt: number): Promise<T> {
542
+ return new Promise<T>((resolve, reject) => {
543
+ let settled = false
544
+
545
+ const timer = setTimeout(() => {
546
+ if (!settled) {
547
+ settled = true
548
+ reject(
549
+ new RetryTimeoutError(`Timed out after ${timeout_ms / 1000}s (attempt ${attempt})`, {
550
+ timeout_seconds: timeout_ms / 1000,
551
+ attempt,
552
+ })
553
+ )
554
+ }
555
+ }, timeout_ms)
556
+
557
+ fn().then(
558
+ (value) => {
559
+ if (!settled) {
560
+ settled = true
561
+ clearTimeout(timer)
562
+ resolve(value)
563
+ }
564
+ },
565
+ (error) => {
566
+ if (!settled) {
567
+ settled = true
568
+ clearTimeout(timer)
569
+ reject(error)
570
+ }
571
+ }
572
+ )
573
+ })
574
+ }
575
+
576
+ function sleep(ms: number): Promise<void> {
577
+ return new Promise((resolve) => setTimeout(resolve, ms))
578
+ }
package/src/timing.ts ADDED
@@ -0,0 +1,52 @@
1
+ export async function _runWithTimeout<T>(timeout_seconds: number | null, on_timeout: () => Error, fn: () => Promise<T>): Promise<T> {
2
+ const task = Promise.resolve().then(fn)
3
+ if (timeout_seconds === null) {
4
+ return await task
5
+ }
6
+ const timeout_ms = timeout_seconds * 1000
7
+ return await new Promise<T>((resolve, reject) => {
8
+ let settled = false
9
+ const finishResolve = (value: T) => {
10
+ if (settled) {
11
+ return
12
+ }
13
+ settled = true
14
+ clearTimeout(timer)
15
+ resolve(value)
16
+ }
17
+ const finishReject = (error: unknown) => {
18
+ if (settled) {
19
+ return
20
+ }
21
+ settled = true
22
+ clearTimeout(timer)
23
+ reject(error)
24
+ }
25
+ const timer = setTimeout(() => {
26
+ if (settled) {
27
+ return
28
+ }
29
+ settled = true
30
+ reject(on_timeout())
31
+ void task.catch(() => undefined)
32
+ }, timeout_ms)
33
+ task.then(finishResolve).catch(finishReject)
34
+ })
35
+ }
36
+
37
+ export async function _runWithSlowMonitor<T>(slow_timer: ReturnType<typeof setTimeout> | null, fn: () => Promise<T>): Promise<T> {
38
+ try {
39
+ return await fn()
40
+ } finally {
41
+ if (slow_timer) {
42
+ clearTimeout(slow_timer)
43
+ }
44
+ }
45
+ }
46
+
47
+ export async function _runWithAbortMonitor<T>(fn: () => T | Promise<T>, abort_signal: Promise<never>): Promise<T> {
48
+ const task = Promise.resolve().then(fn)
49
+ const raced = Promise.race([task, abort_signal])
50
+ void task.catch(() => undefined)
51
+ return await raced
52
+ }