timeout.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
  3. describe('TimeoutReason', () => {
  4. it('is an Error carrying the code and elapsed ms', () => {
  5. const reason = new TimeoutReason('BASH_TIMEOUT', 100)
  6. expect(reason).toBeInstanceOf(Error)
  7. expect(reason.name).toBe('TimeoutReason')
  8. expect(reason.code).toBe('BASH_TIMEOUT')
  9. expect(reason.timeoutMs).toBe(100)
  10. expect(reason.message).toBe('BASH_TIMEOUT after 100ms')
  11. })
  12. })
  13. describe('clampTimeout', () => {
  14. it('fills the default when the hint is absent', () => {
  15. expect(clampTimeout(undefined, 120_000, 600_000)).toBe(120_000)
  16. })
  17. it('caps the hint at max', () => {
  18. expect(clampTimeout(999_999, 120_000, 600_000)).toBe(600_000)
  19. })
  20. it('keeps a valid hint under the cap', () => {
  21. expect(clampTimeout(5_000, 120_000, 600_000)).toBe(5_000)
  22. })
  23. it('caps the default itself when the default exceeds max', () => {
  24. // min(def, max) applies even with no hint — a misconfigured backend never
  25. // exceeds its own cap.
  26. expect(clampTimeout(undefined, 900_000, 600_000)).toBe(600_000)
  27. })
  28. it('rejects a non-finite hint with the caller-provided name', () => {
  29. expect(() => clampTimeout(Number.NaN, 100, 200, 'bash-local: request.timeoutMs'))
  30. .toThrow(/bash-local: request\.timeoutMs must be a positive finite number/)
  31. expect(() => clampTimeout(Number.POSITIVE_INFINITY, 100, 200))
  32. .toThrow(/timeoutMs must be a positive finite number/)
  33. })
  34. it('rejects a non-positive hint', () => {
  35. expect(() => clampTimeout(0, 100, 200)).toThrow(/must be a positive finite number/)
  36. expect(() => clampTimeout(-1, 100, 200)).toThrow(/must be a positive finite number/)
  37. })
  38. })
  39. describe('deadline — timeout arm', () => {
  40. afterEach(() => { vi.useRealTimers() })
  41. it('aborts on timeout with a TimeoutReason after the elapsed ms', () => {
  42. vi.useFakeTimers()
  43. using d = deadline(undefined, 100, 'BASH_TIMEOUT')
  44. expect(d.signal.aborted).toBe(false)
  45. vi.advanceTimersByTime(100)
  46. expect(d.signal.aborted).toBe(true)
  47. const reason = timeoutOf(d.signal)
  48. expect(reason).toBeInstanceOf(TimeoutReason)
  49. expect(reason?.code).toBe('BASH_TIMEOUT')
  50. expect(reason?.timeoutMs).toBe(100)
  51. })
  52. it('[Symbol.dispose] clears the timer so no abort fires afterward', () => {
  53. vi.useFakeTimers()
  54. const d = deadline(undefined, 100, 'BASH_TIMEOUT')
  55. d[Symbol.dispose]()
  56. vi.advanceTimersByTime(1_000)
  57. expect(d.signal.aborted).toBe(false)
  58. expect(timeoutOf(d.signal)).toBeUndefined()
  59. })
  60. })
  61. describe('deadline — fuse with upstream', () => {
  62. it('aborts on upstream cancellation, classified as NOT a timeout', () => {
  63. const upstream = new AbortController()
  64. using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
  65. upstream.abort('user cancelled')
  66. expect(d.signal.aborted).toBe(true)
  67. expect(timeoutOf(d.signal)).toBeUndefined()
  68. })
  69. it('cancel wins when it fires before the timeout', () => {
  70. vi.useFakeTimers()
  71. try {
  72. const upstream = new AbortController()
  73. using d = deadline(upstream.signal, 100, 'BASH_TIMEOUT')
  74. upstream.abort('user cancelled') // fires first, before the 100ms timer
  75. vi.advanceTimersByTime(200)
  76. expect(d.signal.aborted).toBe(true)
  77. // AbortSignal.any adopts the FIRST source's reason: cancel won, so no
  78. // TimeoutReason even though the timer later elapsed.
  79. expect(timeoutOf(d.signal)).toBeUndefined()
  80. } finally {
  81. vi.useRealTimers()
  82. }
  83. })
  84. it('timeout wins when it fires before upstream cancellation', () => {
  85. vi.useFakeTimers()
  86. try {
  87. const upstream = new AbortController()
  88. using d = deadline(upstream.signal, 100, 'WEB_FETCH_TIMEOUT')
  89. vi.advanceTimersByTime(150) // past the 100ms deadline: the timer fires first
  90. expect(d.signal.aborted).toBe(true)
  91. expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
  92. // A later upstream abort is a no-op on the already-aborted fused signal:
  93. // AbortSignal.any keeps the FIRST cause, so the timeout classification stands.
  94. upstream.abort('too late')
  95. expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
  96. } finally {
  97. vi.useRealTimers()
  98. }
  99. })
  100. it('forwards a pre-aborted upstream signal immediately', () => {
  101. const upstream = new AbortController()
  102. upstream.abort('already gone')
  103. using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
  104. expect(d.signal.aborted).toBe(true)
  105. expect(timeoutOf(d.signal)).toBeUndefined()
  106. })
  107. })
  108. describe('deadline — timeoutMs <= 0 (no-timeout sentinel)', () => {
  109. afterEach(() => { vi.useRealTimers() })
  110. it('arms no timer and forwards only the upstream signal', () => {
  111. vi.useFakeTimers()
  112. const upstream = new AbortController()
  113. using d = deadline(upstream.signal, 0, 'BASH_TIMEOUT')
  114. vi.advanceTimersByTime(1_000_000)
  115. expect(d.signal.aborted).toBe(false) // no timer ever armed
  116. upstream.abort('kill')
  117. expect(d.signal.aborted).toBe(true)
  118. expect(timeoutOf(d.signal)).toBeUndefined() // never a timeout
  119. })
  120. it('returns a never-aborting signal with a no-op disposer when there is no upstream', () => {
  121. vi.useFakeTimers()
  122. const d = deadline(undefined, 0, 'BASH_TIMEOUT')
  123. expect(() => { d[Symbol.dispose]() }).not.toThrow()
  124. vi.advanceTimersByTime(1_000_000)
  125. expect(d.signal.aborted).toBe(false)
  126. expect(timeoutOf(d.signal)).toBeUndefined()
  127. })
  128. it('treats a negative timeout the same as zero', () => {
  129. const d = deadline(undefined, -5, 'BASH_TIMEOUT')
  130. expect(d.signal.aborted).toBe(false)
  131. d[Symbol.dispose]()
  132. })
  133. })
  134. describe('timeoutOf', () => {
  135. it('classifies a bare reason carrier that holds a TimeoutReason', () => {
  136. const reason = new TimeoutReason('WEB_FETCH_TIMEOUT', 50)
  137. expect(timeoutOf({ reason })).toBe(reason)
  138. })
  139. it('returns undefined for a non-timeout reason', () => {
  140. expect(timeoutOf({ reason: new Error('other') })).toBeUndefined()
  141. expect(timeoutOf({ reason: 'user cancelled' })).toBeUndefined()
  142. expect(timeoutOf({})).toBeUndefined()
  143. })
  144. it('matches only the requested code when one is given', () => {
  145. const reason = new TimeoutReason('BASH_TIMEOUT', 100)
  146. expect(timeoutOf({ reason }, 'BASH_TIMEOUT')).toBe(reason)
  147. expect(timeoutOf({ reason }, 'WEB_FETCH_TIMEOUT')).toBeUndefined()
  148. })
  149. })
  150. describe('deadline — nested deadlines', () => {
  151. it("does not misclassify an outer deadline's timeout as the inner code", () => {
  152. // The upstream handed to the inner deadline is ITSELF a deadline that has
  153. // already timed out (outer). AbortSignal.any preserves the outer reason;
  154. // scoping timeoutOf to the inner code keeps the inner capability from
  155. // reporting the outer timeout as its own — it reads as an upstream cancel.
  156. const outer = new AbortController()
  157. outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30))
  158. using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT')
  159. expect(inner.signal.aborted).toBe(true)
  160. expect(timeoutOf(inner.signal, 'BASH_TIMEOUT')).toBeUndefined() // not ours → upstream-cancel path
  161. expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped
  162. })
  163. })