timeout.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import {
  3. clampTimeout,
  4. deadline,
  5. idleWatchdog,
  6. MAX_TIMER_DELAY_MS,
  7. timeoutOf,
  8. TimeoutReason,
  9. } from '@deepseek-ai/dsh-timeout'
  10. describe('TimeoutReason', () => {
  11. it('is an Error carrying the code and elapsed ms', () => {
  12. const reason = new TimeoutReason('BASH_TIMEOUT', 100)
  13. expect(reason).toBeInstanceOf(Error)
  14. expect(reason.name).toBe('TimeoutReason')
  15. expect(reason.code).toBe('BASH_TIMEOUT')
  16. expect(reason.timeoutMs).toBe(100)
  17. expect(reason.message).toBe('BASH_TIMEOUT after 100ms')
  18. })
  19. })
  20. describe('clampTimeout', () => {
  21. it('fills the default when the hint is absent', () => {
  22. expect(clampTimeout(undefined, 120_000, 600_000)).toBe(120_000)
  23. })
  24. it('caps the hint at max', () => {
  25. expect(clampTimeout(999_999, 120_000, 600_000)).toBe(600_000)
  26. })
  27. it('keeps a valid hint under the cap', () => {
  28. expect(clampTimeout(5_000, 120_000, 600_000)).toBe(5_000)
  29. })
  30. it('caps the default itself when the default exceeds max', () => {
  31. // min(def, max) applies even with no hint — a misconfigured backend never
  32. // exceeds its own cap.
  33. expect(clampTimeout(undefined, 900_000, 600_000)).toBe(600_000)
  34. })
  35. it('rejects a non-finite hint with the caller-provided name', () => {
  36. expect(() => clampTimeout(Number.NaN, 100, 200, 'bash-local: request.timeoutMs'))
  37. .toThrow(/bash-local: request\.timeoutMs must be a positive finite number/)
  38. expect(() => clampTimeout(Number.POSITIVE_INFINITY, 100, 200))
  39. .toThrow(/timeoutMs must be a positive finite number/)
  40. })
  41. it('rejects a non-positive hint', () => {
  42. expect(() => clampTimeout(0, 100, 200)).toThrow(/must be a positive finite number/)
  43. expect(() => clampTimeout(-1, 100, 200)).toThrow(/must be a positive finite number/)
  44. })
  45. })
  46. describe('deadline — timeout arm', () => {
  47. afterEach(() => { vi.useRealTimers() })
  48. it('aborts on timeout with a TimeoutReason after the elapsed ms', () => {
  49. vi.useFakeTimers()
  50. using d = deadline(undefined, 100, 'BASH_TIMEOUT')
  51. expect(d.signal.aborted).toBe(false)
  52. vi.advanceTimersByTime(100)
  53. expect(d.signal.aborted).toBe(true)
  54. const reason = timeoutOf(d.signal)
  55. expect(reason).toBeInstanceOf(TimeoutReason)
  56. expect(reason?.code).toBe('BASH_TIMEOUT')
  57. expect(reason?.timeoutMs).toBe(100)
  58. })
  59. it('[Symbol.dispose] clears the timer so no abort fires afterward', () => {
  60. vi.useFakeTimers()
  61. const d = deadline(undefined, 100, 'BASH_TIMEOUT')
  62. d[Symbol.dispose]()
  63. vi.advanceTimersByTime(1_000)
  64. expect(d.signal.aborted).toBe(false)
  65. expect(timeoutOf(d.signal)).toBeUndefined()
  66. })
  67. it('rejects delays that Node would clamp to one millisecond', () => {
  68. expect(() => deadline(undefined, MAX_TIMER_DELAY_MS + 1, 'BASH_TIMEOUT'))
  69. .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
  70. expect(() => deadline(undefined, Number.POSITIVE_INFINITY, 'BASH_TIMEOUT'))
  71. .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
  72. })
  73. })
  74. describe('deadline — fuse with upstream', () => {
  75. it('aborts on upstream cancellation, classified as NOT a timeout', () => {
  76. const upstream = new AbortController()
  77. using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
  78. upstream.abort('user cancelled')
  79. expect(d.signal.aborted).toBe(true)
  80. expect(timeoutOf(d.signal)).toBeUndefined()
  81. })
  82. it('cancel wins when it fires before the timeout', () => {
  83. vi.useFakeTimers()
  84. try {
  85. const upstream = new AbortController()
  86. using d = deadline(upstream.signal, 100, 'BASH_TIMEOUT')
  87. upstream.abort('user cancelled') // fires first, before the 100ms timer
  88. vi.advanceTimersByTime(200)
  89. expect(d.signal.aborted).toBe(true)
  90. // AbortSignal.any adopts the FIRST source's reason: cancel won, so no
  91. // TimeoutReason even though the timer later elapsed.
  92. expect(timeoutOf(d.signal)).toBeUndefined()
  93. } finally {
  94. vi.useRealTimers()
  95. }
  96. })
  97. it('timeout wins when it fires before upstream cancellation', () => {
  98. vi.useFakeTimers()
  99. try {
  100. const upstream = new AbortController()
  101. using d = deadline(upstream.signal, 100, 'WEB_FETCH_TIMEOUT')
  102. vi.advanceTimersByTime(150) // past the 100ms deadline: the timer fires first
  103. expect(d.signal.aborted).toBe(true)
  104. expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
  105. // A later upstream abort is a no-op on the already-aborted fused signal:
  106. // AbortSignal.any keeps the FIRST cause, so the timeout classification stands.
  107. upstream.abort('too late')
  108. expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
  109. } finally {
  110. vi.useRealTimers()
  111. }
  112. })
  113. it('forwards a pre-aborted upstream signal immediately', () => {
  114. const upstream = new AbortController()
  115. upstream.abort('already gone')
  116. using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
  117. expect(d.signal.aborted).toBe(true)
  118. expect(timeoutOf(d.signal)).toBeUndefined()
  119. })
  120. })
  121. describe('deadline — timeoutMs <= 0 (no-timeout sentinel)', () => {
  122. afterEach(() => { vi.useRealTimers() })
  123. it('arms no timer and forwards only the upstream signal', () => {
  124. vi.useFakeTimers()
  125. const upstream = new AbortController()
  126. using d = deadline(upstream.signal, 0, 'BASH_TIMEOUT')
  127. vi.advanceTimersByTime(1_000_000)
  128. expect(d.signal.aborted).toBe(false) // no timer ever armed
  129. upstream.abort('kill')
  130. expect(d.signal.aborted).toBe(true)
  131. expect(timeoutOf(d.signal)).toBeUndefined() // never a timeout
  132. })
  133. it('returns a never-aborting signal with a no-op disposer when there is no upstream', () => {
  134. vi.useFakeTimers()
  135. const d = deadline(undefined, 0, 'BASH_TIMEOUT')
  136. expect(() => { d[Symbol.dispose]() }).not.toThrow()
  137. vi.advanceTimersByTime(1_000_000)
  138. expect(d.signal.aborted).toBe(false)
  139. expect(timeoutOf(d.signal)).toBeUndefined()
  140. })
  141. it('treats a negative timeout the same as zero', () => {
  142. const d = deadline(undefined, -5, 'BASH_TIMEOUT')
  143. expect(d.signal.aborted).toBe(false)
  144. d[Symbol.dispose]()
  145. })
  146. })
  147. describe('timeoutOf', () => {
  148. it('classifies a bare reason carrier that holds a TimeoutReason', () => {
  149. const reason = new TimeoutReason('WEB_FETCH_TIMEOUT', 50)
  150. expect(timeoutOf({ reason })).toBe(reason)
  151. })
  152. it('returns undefined for a non-timeout reason', () => {
  153. expect(timeoutOf({ reason: new Error('other') })).toBeUndefined()
  154. expect(timeoutOf({ reason: 'user cancelled' })).toBeUndefined()
  155. expect(timeoutOf({})).toBeUndefined()
  156. })
  157. it('matches only the requested code when one is given', () => {
  158. const reason = new TimeoutReason('BASH_TIMEOUT', 100)
  159. expect(timeoutOf({ reason }, 'BASH_TIMEOUT')).toBe(reason)
  160. expect(timeoutOf({ reason }, 'WEB_FETCH_TIMEOUT')).toBeUndefined()
  161. })
  162. })
  163. describe('deadline — nested deadlines', () => {
  164. it("does not misclassify an outer deadline's timeout as the inner code", () => {
  165. // The upstream handed to the inner deadline is ITSELF a deadline that has already timed out
  166. // (outer). `AbortSignal.any` preserves that reason, but scoping `timeoutOf` to the inner code
  167. // must classify it as upstream cancellation rather than the inner capability's timeout.
  168. const outer = new AbortController()
  169. outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30))
  170. using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT')
  171. expect(inner.signal.aborted).toBe(true)
  172. expect(timeoutOf(inner.signal, 'BASH_TIMEOUT')).toBeUndefined() // not ours → upstream-cancel path
  173. expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped
  174. })
  175. })
  176. describe('idleWatchdog', () => {
  177. afterEach(() => { vi.useRealTimers() })
  178. it('arms only while next is outstanding and rearms the same signal for later demand', async () => {
  179. vi.useFakeTimers()
  180. const first = Promise.withResolvers<IteratorResult<number>>()
  181. const second = Promise.withResolvers<IteratorResult<number>>()
  182. const iterator: AsyncIterator<number> = {
  183. next: vi.fn()
  184. .mockImplementationOnce(() => first.promise)
  185. .mockImplementationOnce(() => second.promise),
  186. }
  187. using watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT')
  188. const stableSignal = watchdog.signal
  189. const firstNext = watchdog.next(iterator)
  190. await vi.advanceTimersByTimeAsync(99)
  191. expect(stableSignal.aborted).toBe(false)
  192. first.resolve({ done: false, value: 1 })
  193. await expect(firstNext).resolves.toEqual({ done: false, value: 1 })
  194. await vi.advanceTimersByTimeAsync(10_000)
  195. expect(stableSignal.aborted).toBe(false)
  196. expect(watchdog.signal).toBe(stableSignal)
  197. const secondNext = watchdog.next(iterator)
  198. await vi.advanceTimersByTimeAsync(100)
  199. expect(timeoutOf(stableSignal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 })
  200. second.reject(stableSignal.reason)
  201. await expect(secondNext).rejects.toBe(stableSignal.reason)
  202. })
  203. it('rearms outstanding demand on an out-of-band activity pulse', async () => {
  204. vi.useFakeTimers()
  205. const pending = Promise.withResolvers<IteratorResult<number>>()
  206. const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT')
  207. watchdog.pulse()
  208. await vi.advanceTimersByTimeAsync(1_000)
  209. expect(watchdog.signal.aborted).toBe(false)
  210. const next = watchdog.next({ next: () => pending.promise })
  211. await vi.advanceTimersByTimeAsync(99)
  212. watchdog.pulse()
  213. await vi.advanceTimersByTimeAsync(99)
  214. expect(watchdog.signal.aborted).toBe(false)
  215. await vi.advanceTimersByTimeAsync(1)
  216. expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 })
  217. pending.reject(watchdog.signal.reason)
  218. await expect(next).rejects.toBe(watchdog.signal.reason)
  219. watchdog[Symbol.dispose]()
  220. watchdog.pulse()
  221. })
  222. it('keeps an earlier upstream abort distinct from its own timeout', async () => {
  223. vi.useFakeTimers()
  224. const upstream = new AbortController()
  225. using watchdog = idleWatchdog(upstream.signal, 100, 'LLM_STREAM_IDLE_TIMEOUT')
  226. upstream.abort('caller cancelled')
  227. expect(watchdog.signal.aborted).toBe(true)
  228. expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toBeUndefined()
  229. await vi.advanceTimersByTimeAsync(1_000)
  230. expect(watchdog.signal.reason).toBe('caller cancelled')
  231. })
  232. it('clears an outstanding arm on disposal', async () => {
  233. vi.useFakeTimers()
  234. const pending = Promise.withResolvers<IteratorResult<number>>()
  235. const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT')
  236. void watchdog.next({ next: () => pending.promise })
  237. watchdog[Symbol.dispose]()
  238. await vi.advanceTimersByTimeAsync(1_000)
  239. expect(watchdog.signal.aborted).toBe(false)
  240. pending.resolve({ done: true, value: undefined })
  241. await expect(watchdog.next({ next: () => Promise.resolve({ done: true, value: undefined }) }))
  242. .rejects.toThrow(/disposed/)
  243. watchdog[Symbol.dispose]()
  244. })
  245. it('rejects invalid bounds and concurrent iterator demand', async () => {
  246. expect(() => idleWatchdog(undefined, 0, 'IDLE')).toThrow(/positive finite/)
  247. expect(() => idleWatchdog(undefined, Number.NaN, 'IDLE')).toThrow(/positive finite/)
  248. expect(() => idleWatchdog(undefined, MAX_TIMER_DELAY_MS + 1, 'IDLE'))
  249. .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
  250. const pending = Promise.withResolvers<IteratorResult<number>>()
  251. using watchdog = idleWatchdog(undefined, 100, 'IDLE')
  252. const iterator = { next: () => pending.promise }
  253. void watchdog.next(iterator)
  254. await expect(watchdog.next(iterator)).rejects.toThrow(/already outstanding/)
  255. pending.resolve({ done: true, value: undefined })
  256. })
  257. })