index.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  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. /**
  22. * Validate a caller's optional timeout hint, use the backend default, then cap
  23. * it. Supplied values must be positive and finite; zero is not a public
  24. * disable-timeout sentinel.
  25. *
  26. * @param requested The caller's optional hint; validated when present.
  27. * @param def The backend default applied when `requested` is absent.
  28. * @param max The backend upper bound the result is capped to.
  29. * @param name Field name used in the thrown message (so the caller sees which input was
  30. * bad).
  31. * @returns The effective timeout in milliseconds: `min(requested ?? def, max)`.
  32. */
  33. export function clampTimeout(
  34. requested: number | undefined,
  35. def: number,
  36. max: number,
  37. name = 'timeoutMs',
  38. ): number {
  39. if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) {
  40. throw new Error(`${name} must be a positive finite number`)
  41. }
  42. return Math.min(requested ?? def, max)
  43. }
  44. /** A deadline signal plus the cleanup that clears its timer (dispose-once). */
  45. export interface Deadline {
  46. /** Aborts on upstream cancellation OR on timeout (the timeout carries a {@link TimeoutReason}). */
  47. readonly signal: AbortSignal
  48. /** Clear the timer. Safe to call once; `using` calls it at scope exit. */
  49. [Symbol.dispose](): void
  50. }
  51. /**
  52. * Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is
  53. * the internal no-timer sentinel; the returned disposer clears an armed timer.
  54. * The signal only notifies, so callers must stop their own work.
  55. *
  56. * @param upstream The caller's cancellation signal, if any, fused into the result.
  57. * @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer).
  58. * @param code Capability-owned code stamped onto the timeout's {@link TimeoutReason}.
  59. * @returns The fused {@link Deadline} (signal + timer cleanup).
  60. */
  61. export function deadline(
  62. upstream: AbortSignal | undefined,
  63. timeoutMs: number,
  64. code: string,
  65. ): Deadline {
  66. if (timeoutMs <= 0) {
  67. // No timeout (background work): forward only the upstream signal, or a never-aborting one
  68. // when there is no upstream.
  69. return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} }
  70. }
  71. const timer = new AbortController()
  72. const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs)
  73. return {
  74. // AbortSignal.any adopts the reason of whichever source aborts FIRST, so a
  75. // race resolves to a single cause: timeoutOf() reads TimeoutReason only
  76. // when the timeout won, and upstream-wins leaves an ordinary abort reason.
  77. signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
  78. [Symbol.dispose]() { clearTimeout(id) },
  79. }
  80. }
  81. /**
  82. * Recover a timeout reason from a reason-bearing object. Supplying `code`
  83. * distinguishes this deadline from a nested upstream deadline; a foreign code
  84. * follows the ordinary cancellation path.
  85. *
  86. * @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error).
  87. * @param code When provided, only a {@link TimeoutReason} with this exact `code` matches.
  88. * @returns The matching {@link TimeoutReason}, else `undefined`.
  89. */
  90. export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined {
  91. // AbortSignal.reason is typed `any`; pin it to `unknown` so no `any` leaks and
  92. // the instanceof narrows cleanly for both a signal and a bare reason carrier.
  93. const reason: unknown = x.reason
  94. if (!(reason instanceof TimeoutReason)) return undefined
  95. return code === undefined || reason.code === code ? reason : undefined
  96. }