index.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. /**
  2. * The timing-and-classification half of a timeout — a zero-dependency library
  3. * of pure functions shared by every capability that clamps a caller's timeout
  4. * hint, arms a deadline, and later has to tell "timed out" apart from
  5. * "cancelled". It owns NO termination: the returned {@link deadline} signal only
  6. * NOTIFIES; actually stopping the work (SIGKILL a process group, tear down a
  7. * fetch socket, …) stays in each capability's implementation, because that
  8. * mechanism differs per capability and no shared layer can own all of them.
  9. *
  10. * This is deliberately a library, not a cordis service or plugin: it takes no
  11. * `ctx`, registers nothing, holds no cross-call state, and emits no events. A
  12. * "timeout service" would have to understand how to stop every capability's
  13. * work — exactly the knowledge a microkernel keeps out of shared layers.
  14. *
  15. * The four exports and their division of labor:
  16. * - {@link clampTimeout} — validate a caller's optional positive hint, fill the
  17. * backend default, cap at the backend max (pure arithmetic + the shared
  18. * positive-finite request contract).
  19. * - {@link deadline} — fuse upstream cancellation with a timeout into one
  20. * `AbortSignal`, the timeout carrying an identifiable {@link TimeoutReason};
  21. * `[Symbol.dispose]` clears the timer.
  22. * - {@link timeoutOf} — classify an aborted signal (or error): a
  23. * {@link TimeoutReason} means the timeout fired, anything else (or nothing)
  24. * means it did not.
  25. * - {@link TimeoutReason} — the internal classification reason; providers
  26. * translate it into their own public error/result shape before returning.
  27. *
  28. * @module @deepseek-ai/dsh-timeout
  29. */
  30. /**
  31. * The internal reason attached to a timeout abort so consumers can classify it
  32. * after the fact. It carries the failing `code` (each capability's own string —
  33. * `BASH_TIMEOUT`, `WEB_FETCH_TIMEOUT`, …) and the `timeoutMs` that elapsed.
  34. *
  35. * It is an INTERNAL classification reason, not a public error: providers
  36. * translate it into their seam-specific error code or result field (via
  37. * {@link timeoutOf}) before returning to callers. Native `AbortSignal.timeout()`
  38. * yields a fixed `TimeoutError` indistinguishable across timeout kinds; this
  39. * type is identifiable and carries the code/duration.
  40. */
  41. export class TimeoutReason extends Error {
  42. override name = 'TimeoutReason'
  43. /**
  44. * @param code Capability-owned timeout code (e.g. `BASH_TIMEOUT`).
  45. * @param timeoutMs The deadline that elapsed, in milliseconds.
  46. */
  47. constructor(readonly code: string, readonly timeoutMs: number) {
  48. super(`${code} after ${timeoutMs}ms`)
  49. }
  50. }
  51. /**
  52. * Validate a caller's optional timeout hint, fill it from the backend default,
  53. * then cap at the backend max. The shared positive-finite request contract:
  54. * a supplied `requested` must be a positive finite number or this throws —
  55. * `0` is NOT a caller-facing "disable timeout" value (that sentinel is internal
  56. * to {@link deadline}). A missing `requested` falls back to `def`.
  57. *
  58. * @param requested The caller's optional hint; validated when present.
  59. * @param def The backend default applied when `requested` is absent.
  60. * @param max The backend upper bound the result is capped to.
  61. * @param name Field name used in the thrown message (so the caller sees which input was bad).
  62. * @returns The effective timeout in milliseconds: `min(requested ?? def, max)`.
  63. */
  64. export function clampTimeout(
  65. requested: number | undefined,
  66. def: number,
  67. max: number,
  68. name = 'timeoutMs',
  69. ): number {
  70. if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) {
  71. throw new Error(`${name} must be a positive finite number`)
  72. }
  73. return Math.min(requested ?? def, max)
  74. }
  75. /** A deadline signal plus the cleanup that clears its timer (dispose-once). */
  76. export interface Deadline {
  77. /** Aborts on upstream cancellation OR on timeout (the timeout carries a {@link TimeoutReason}). */
  78. readonly signal: AbortSignal
  79. /** Clear the timer. Safe to call once; `using` calls it at scope exit. */
  80. [Symbol.dispose](): void
  81. }
  82. /**
  83. * Build a deadline signal that aborts on upstream cancellation OR on timeout,
  84. * with the timeout carrying an identifiable {@link TimeoutReason} (unlike
  85. * native `AbortSignal.timeout()`, whose fixed `TimeoutError` is opaque). It is
  86. * `AbortSignal.any([upstream, <timeout>])` — the single primitive that fuses
  87. * two abort sources — with the reason and a disposable timer added on top.
  88. *
  89. * `timeoutMs <= 0` is the INTERNAL "no timeout" sentinel for backend-owned
  90. * background work: arm no timer and forward only the upstream signal; with no
  91. * upstream either, return a never-aborting signal so callers keep one call
  92. * shape. External request hints validate as positive finite via
  93. * {@link clampTimeout} before reaching here, so `0` never arrives from a model
  94. * or plugin.
  95. *
  96. * The returned object's `[Symbol.dispose]` clears the timer — use `using` for a
  97. * scope-lifetime consumer, or call it manually for an event-lifetime one. The
  98. * signal only NOTIFIES; the caller must attach its own termination (kill the
  99. * process group, abort the fetch, …).
  100. *
  101. * @param upstream The caller's cancellation signal, if any, fused into the result.
  102. * @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer).
  103. * @param code Capability-owned code stamped onto the timeout's {@link TimeoutReason}.
  104. * @returns The fused {@link Deadline} (signal + timer cleanup).
  105. */
  106. export function deadline(
  107. upstream: AbortSignal | undefined,
  108. timeoutMs: number,
  109. code: string,
  110. ): Deadline {
  111. if (timeoutMs <= 0) {
  112. // No timeout (background work): forward only the upstream signal, or a
  113. // never-aborting one when there is no upstream. No timer, so dispose is a
  114. // no-op — the empty method keeps the one call shape for every caller.
  115. return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} }
  116. }
  117. const timer = new AbortController()
  118. const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs)
  119. return {
  120. // AbortSignal.any adopts the reason of whichever source aborts FIRST, so a
  121. // race resolves to a single cause: timeoutOf() reads TimeoutReason only
  122. // when the timeout won, and upstream-wins leaves an ordinary abort reason.
  123. signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
  124. [Symbol.dispose]() { clearTimeout(id) },
  125. }
  126. }
  127. /**
  128. * Recover the {@link TimeoutReason} from an aborted signal (or any object with a
  129. * `reason`), else `undefined`. This is the classification half: a provider
  130. * calls it on the deadline signal after an abort to decide whether the cause
  131. * was its timeout (translate to the capability's timeout error/field) or an
  132. * ordinary upstream cancellation (`undefined` → the cancel path).
  133. *
  134. * Pass `code` to scope the match to THIS deadline's timer. It matters under
  135. * nesting: when the `upstream` handed to {@link deadline} is itself a deadline
  136. * signal (e.g. a future `tools/execute` middleware arming a per-call deadline),
  137. * `AbortSignal.any` preserves the OUTER `TimeoutReason` if the outer timer fires
  138. * first. Without `code`, the inner capability would misclassify that outer
  139. * timeout as its own (`timedOut:true` / `WEB_FETCH_TIMEOUT`) though its local
  140. * timer never expired; with `code`, a foreign timeout reads as `undefined` and
  141. * falls through to the upstream-cancel path, which is the correct classification
  142. * from the inner capability's view. Omit `code` only to ask "was this ANY
  143. * timeout" (a generic middleware that owns no single code).
  144. *
  145. * @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error).
  146. * @param code When provided, only a {@link TimeoutReason} with this exact `code` matches.
  147. * @returns The matching {@link TimeoutReason}, else `undefined`.
  148. */
  149. export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined {
  150. // AbortSignal.reason is typed `any`; pin it to `unknown` so no `any` leaks and
  151. // the instanceof narrows cleanly for both a signal and a bare reason carrier.
  152. const reason: unknown = x.reason
  153. if (!(reason instanceof TimeoutReason)) return undefined
  154. return code === undefined || reason.code === code ? reason : undefined
  155. }