index.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. /**
  2. * Shared timeout arithmetic, signal fusion, and classification. The library
  3. * only notifies through abort signals; each capability still owns the mechanism
  4. * that stops its work and translates timeout reasons into public outcomes.
  5. * @module @deepseek-ai/dsh-timeout
  6. */
  7. /**
  8. * Internal abort reason carrying a capability-owned code and elapsed deadline.
  9. * Providers translate it through {@link timeoutOf} before returning to callers.
  10. */
  11. export class TimeoutReason extends Error {
  12. override name = 'TimeoutReason'
  13. /**
  14. * @param code Capability-owned timeout code (e.g. `BASH_TIMEOUT`).
  15. * @param timeoutMs The deadline that elapsed, in milliseconds.
  16. */
  17. constructor(readonly code: string, readonly timeoutMs: number) {
  18. super(`${code} after ${timeoutMs}ms`)
  19. }
  20. }
  21. /** Largest delay Node schedules without clamping it to one millisecond. */
  22. export const MAX_TIMER_DELAY_MS = 2_147_483_647
  23. function assertTimerDelay(timeoutMs: number, name: string): void {
  24. if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) {
  25. throw new Error(`${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
  26. }
  27. }
  28. /**
  29. * Validate a caller's optional timeout hint, use the backend default, then cap
  30. * it. Supplied values must be positive and finite; zero is not a public
  31. * disable-timeout sentinel.
  32. *
  33. * @param requested The caller's optional hint; validated when present.
  34. * @param def The backend default applied when `requested` is absent.
  35. * @param max The backend upper bound the result is capped to.
  36. * @param name Field name used in the thrown message (so the caller sees which input was
  37. * bad).
  38. * @returns The effective timeout in milliseconds: `min(requested ?? def, max)`.
  39. */
  40. export function clampTimeout(
  41. requested: number | undefined,
  42. def: number,
  43. max: number,
  44. name = 'timeoutMs',
  45. ): number {
  46. if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) {
  47. throw new Error(`${name} must be a positive finite number`)
  48. }
  49. return Math.min(requested ?? def, max)
  50. }
  51. /** A deadline signal plus the cleanup that clears its timer (dispose-once). */
  52. export interface Deadline {
  53. /** Aborts on upstream cancellation OR on timeout (the timeout carries a {@link TimeoutReason}). */
  54. readonly signal: AbortSignal
  55. /** Clear the timer. Safe to call once; `using` calls it at scope exit. */
  56. [Symbol.dispose](): void
  57. }
  58. /** Rearmable timeout around one outstanding async-iterator demand. */
  59. export interface IdleWatchdog {
  60. /** Stable signal aborted by upstream cancellation or this watchdog's timeout. */
  61. readonly signal: AbortSignal
  62. /**
  63. * Await one iterator demand while the idle timer is armed.
  64. * @param iterator - iterator whose next value represents provider progress.
  65. * @returns the iterator's next result.
  66. */
  67. next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>>
  68. /** Rearm an outstanding demand after transport activity that yields no iterator value; otherwise a no-op. */
  69. pulse(): void
  70. /** Clear an armed timer; safe to call once at the owning stream's exit. */
  71. [Symbol.dispose](): void
  72. }
  73. /**
  74. * Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is
  75. * the internal no-timer sentinel; the returned disposer clears an armed timer.
  76. * The signal only notifies, so callers must stop their own work.
  77. *
  78. * @param upstream The caller's cancellation signal, if any, fused into the result.
  79. * @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer).
  80. * @param code Capability-owned code stamped onto the timeout's {@link TimeoutReason}.
  81. * @returns The fused {@link Deadline} (signal + timer cleanup).
  82. */
  83. export function deadline(
  84. upstream: AbortSignal | undefined,
  85. timeoutMs: number,
  86. code: string,
  87. ): Deadline {
  88. if (timeoutMs <= 0) {
  89. // No timeout (background work): forward only the upstream signal, or a never-aborting one
  90. // when there is no upstream.
  91. return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} }
  92. }
  93. assertTimerDelay(timeoutMs, 'deadline timeoutMs')
  94. const timer = new AbortController()
  95. const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs)
  96. return {
  97. // AbortSignal.any adopts the reason of whichever source aborts FIRST, so a
  98. // race resolves to a single cause: timeoutOf() reads TimeoutReason only
  99. // when the timeout won, and upstream-wins leaves an ordinary abort reason.
  100. signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
  101. [Symbol.dispose]() { clearTimeout(id) },
  102. }
  103. }
  104. /**
  105. * Create a rearmable idle watchdog for an async iterator. The timer exists only
  106. * while {@link IdleWatchdog.next} is outstanding, so consumer think time does
  107. * not count as provider idle time. The returned signal is stable for the whole
  108. * call and only notifies; the iterator must observe it to terminate its work.
  109. *
  110. * @param upstream - caller cancellation fused into the stable signal.
  111. * @param timeoutMs - positive finite idle interval in milliseconds.
  112. * @param code - capability-owned code carried by the timeout reason.
  113. * @returns a stable signal, guarded next operation, and timer disposer.
  114. */
  115. export function idleWatchdog(
  116. upstream: AbortSignal | undefined,
  117. timeoutMs: number,
  118. code: string,
  119. ): IdleWatchdog {
  120. assertTimerDelay(timeoutMs, 'idleWatchdog timeoutMs')
  121. const timeout = new AbortController()
  122. const signal = upstream === undefined
  123. ? timeout.signal
  124. : AbortSignal.any([upstream, timeout.signal])
  125. let timer: ReturnType<typeof setTimeout> | undefined
  126. let outstanding = false
  127. let disposed = false
  128. const arm = (): void => {
  129. if (timer !== undefined) clearTimeout(timer)
  130. timer = setTimeout(() => {
  131. timeout.abort(new TimeoutReason(code, timeoutMs))
  132. }, timeoutMs)
  133. }
  134. return {
  135. signal,
  136. async next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>> {
  137. if (disposed) throw new Error('idleWatchdog is disposed')
  138. if (outstanding) throw new Error('idleWatchdog next is already outstanding')
  139. outstanding = true
  140. arm()
  141. try {
  142. return await iterator.next()
  143. } finally {
  144. clearTimeout(timer)
  145. timer = undefined
  146. outstanding = false
  147. }
  148. },
  149. pulse(): void {
  150. if (disposed || !outstanding) return
  151. arm()
  152. },
  153. [Symbol.dispose](): void {
  154. if (disposed) return
  155. disposed = true
  156. if (timer !== undefined) clearTimeout(timer)
  157. timer = undefined
  158. },
  159. }
  160. }
  161. /**
  162. * Recover a timeout reason from a reason-bearing object. Supplying `code`
  163. * distinguishes this deadline from a nested upstream deadline; a foreign code
  164. * follows the ordinary cancellation path.
  165. *
  166. * @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error).
  167. * @param code When provided, only a {@link TimeoutReason} with this exact `code` matches.
  168. * @returns The matching {@link TimeoutReason}, else `undefined`.
  169. */
  170. export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined {
  171. // AbortSignal.reason is typed `any`; pin it to `unknown` so no `any` leaks and
  172. // the instanceof narrows cleanly for both a signal and a bare reason carrier.
  173. const reason: unknown = x.reason
  174. if (!(reason instanceof TimeoutReason)) return undefined
  175. return code === undefined || reason.code === code ? reason : undefined
  176. }