preparations.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. /** Unit coverage for unpublished Session preparation ownership and sharing. */
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import { observeQueuedAbort, SessionPreparations } from '../src/preparations.ts'
  5. interface PreparedSource {
  6. readonly session: Session
  7. readonly label: string
  8. }
  9. function prepared(label: string): PreparedSource {
  10. return { session: Session.create(SessionId(label)), label }
  11. }
  12. function committed(source: PreparedSource): Promise<{ source: PreparedSource; state: string }> {
  13. return Promise.resolve({ source, state: source.label })
  14. }
  15. describe('SessionPreparations inspection', () => {
  16. it('shares in-flight and ready sources, then invalidates them', async () => {
  17. const preparations = new SessionPreparations<PreparedSource, string>(2)
  18. const id = SessionId('shared-inspection')
  19. const gate = Promise.withResolvers<PreparedSource>()
  20. const load = vi.fn(() => gate.promise)
  21. const first = preparations.inspect(id, load)
  22. const second = preparations.inspect(id, load, new AbortController().signal)
  23. const source = prepared(id)
  24. expect(preparations.has(id)).toBe(true)
  25. gate.resolve(source)
  26. await expect(first).resolves.toBe(source)
  27. await expect(second).resolves.toBe(source)
  28. await expect(preparations.inspect(id, load)).resolves.toBe(source)
  29. expect(load).toHaveBeenCalledOnce()
  30. preparations.invalidate(id)
  31. preparations.invalidate(id)
  32. expect(preparations.has(id)).toBe(false)
  33. })
  34. it('keeps a shared load alive when its first observer cancels', async () => {
  35. const preparations = new SessionPreparations<PreparedSource, string>(1)
  36. const id = SessionId('cancelled-first-observer')
  37. const gate = Promise.withResolvers<PreparedSource>()
  38. const load = vi.fn(() => gate.promise)
  39. const controller = new AbortController()
  40. const reason = new Error('first observer cancelled')
  41. const first = preparations.inspect(id, load, controller.signal)
  42. const joined = preparations.inspect(id, load)
  43. controller.abort(reason)
  44. await expect(first).rejects.toBe(reason)
  45. const source = prepared(id)
  46. gate.resolve(source)
  47. await expect(joined).resolves.toBe(source)
  48. await expect(preparations.inspect(id, load)).resolves.toBe(source)
  49. expect(load).toHaveBeenCalledOnce()
  50. })
  51. it('evicts completed loads whose observers cancelled before readiness', async () => {
  52. const preparations = new SessionPreparations<PreparedSource, string>(1)
  53. const firstId = SessionId('cancelled-ready-first')
  54. const secondId = SessionId('cancelled-ready-second')
  55. const firstGate = Promise.withResolvers<PreparedSource>()
  56. const secondGate = Promise.withResolvers<PreparedSource>()
  57. const firstController = new AbortController()
  58. const secondController = new AbortController()
  59. const first = preparations.inspect(firstId, () => firstGate.promise, firstController.signal)
  60. const second = preparations.inspect(secondId, () => secondGate.promise, secondController.signal)
  61. firstController.abort(new Error('first observer cancelled'))
  62. secondController.abort(new Error('second observer cancelled'))
  63. await expect(first).rejects.toThrow('first observer cancelled')
  64. await expect(second).rejects.toThrow('second observer cancelled')
  65. firstGate.resolve(prepared(firstId))
  66. await firstGate.promise
  67. secondGate.resolve(prepared(secondId))
  68. await secondGate.promise
  69. await Promise.resolve()
  70. expect(preparations.has(firstId)).toBe(false)
  71. expect(preparations.has(secondId)).toBe(true)
  72. })
  73. it('removes failed and invalidated in-flight loads without changing their observers', async () => {
  74. const preparations = new SessionPreparations<PreparedSource, string>(1)
  75. const failedId = SessionId('failed-inspection')
  76. const failure = new Error('load failed')
  77. await expect(preparations.inspect(failedId, () => Promise.reject(failure))).rejects.toBe(failure)
  78. expect(preparations.has(failedId)).toBe(false)
  79. const invalidatedId = SessionId('invalidated-inspection')
  80. const gate = Promise.withResolvers<PreparedSource>()
  81. const inspection = preparations.inspect(invalidatedId, () => gate.promise)
  82. preparations.invalidate(invalidatedId)
  83. const source = prepared(invalidatedId)
  84. gate.resolve(source)
  85. await expect(inspection).resolves.toBe(source)
  86. expect(preparations.has(invalidatedId)).toBe(false)
  87. const rejectedId = SessionId('invalidated-rejection')
  88. const rejectedGate = Promise.withResolvers<PreparedSource>()
  89. const rejected = preparations.inspect(rejectedId, () => rejectedGate.promise)
  90. preparations.invalidate(rejectedId)
  91. rejectedGate.reject(failure)
  92. await expect(rejected).rejects.toBe(failure)
  93. })
  94. it('removes a load that throws before returning its promise', async () => {
  95. const preparations = new SessionPreparations<PreparedSource, string>(1)
  96. const id = SessionId('synchronous-load-failure')
  97. const failure = new Error('synchronous load failure')
  98. await expect(preparations.inspect(id, () => { throw failure })).rejects.toBe(failure)
  99. expect(preparations.has(id)).toBe(false)
  100. })
  101. it('evicts ready entries while leaving reserved entries alone', async () => {
  102. const preparations = new SessionPreparations<PreparedSource, string>(1)
  103. const reservedA = await preparations.reserve(
  104. SessionId('reserved-a'),
  105. () => Promise.resolve(prepared('reserved-a')),
  106. committed,
  107. )
  108. const reservedB = await preparations.reserve(
  109. SessionId('reserved-b'),
  110. () => Promise.resolve(prepared('reserved-b')),
  111. committed,
  112. )
  113. expect(reservedA).toBeDefined()
  114. expect(reservedB).toBeDefined()
  115. await preparations.inspect(SessionId('ready-c'), () => Promise.resolve(prepared('ready-c')))
  116. preparations.release(reservedA!, true)
  117. expect(preparations.has(SessionId('reserved-b'))).toBe(true)
  118. expect(preparations.has(SessionId('ready-c'))).toBe(false)
  119. expect(preparations.has(SessionId('reserved-a'))).toBe(true)
  120. preparations.discard(reservedB!)
  121. preparations.invalidate(SessionId('reserved-a'))
  122. })
  123. it('discards only the exact ready source and retains exclusive reservations', async () => {
  124. const preparations = new SessionPreparations<PreparedSource, string>(1)
  125. const ready = prepared('discard-ready')
  126. expect(preparations.discardReady(ready.session.id, ready)).toBe('missing')
  127. await preparations.inspect(ready.session.id, () => Promise.resolve(ready))
  128. expect(preparations.discardReady(ready.session.id, prepared('different'))).toBe('missing')
  129. expect(preparations.discardReady(ready.session.id, ready)).toBe('discarded')
  130. const reserved = await preparations.reserve(
  131. ready.session.id,
  132. () => Promise.resolve(ready),
  133. committed,
  134. )
  135. expect(preparations.discardReady(ready.session.id, ready)).toBe('retained')
  136. preparations.release(reserved!, false)
  137. })
  138. })
  139. describe('SessionPreparations borrowing', () => {
  140. it('returns a detached lease when loading invalidates its own entry', async () => {
  141. const preparations = new SessionPreparations<PreparedSource, string>(1)
  142. const id = SessionId('borrow-invalidated-load')
  143. const source = prepared(id)
  144. const lease = await preparations.borrow(id, () => {
  145. preparations.invalidate(id)
  146. return Promise.resolve(source)
  147. })
  148. expect(lease.source).toBe(source)
  149. expect(preparations.has(id)).toBe(false)
  150. expect(() => { lease[Symbol.dispose]() }).not.toThrow()
  151. })
  152. it('releases pins after cancellation while loading and after readiness', async () => {
  153. const preparations = new SessionPreparations<PreparedSource, string>(1)
  154. const loadingId = SessionId('borrow-cancelled-loading')
  155. const loading = Promise.withResolvers<PreparedSource>()
  156. const loadingAbort = new AbortController()
  157. const pending = preparations.borrow(loadingId, () => loading.promise, loadingAbort.signal)
  158. loadingAbort.abort(new Error('cancelled while loading'))
  159. await expect(pending).rejects.toThrow('cancelled while loading')
  160. loading.resolve(prepared(loadingId))
  161. await loading.promise
  162. await Promise.resolve()
  163. const readyId = SessionId('borrow-cancelled-ready')
  164. const ready = prepared(readyId)
  165. await preparations.inspect(readyId, () => Promise.resolve(ready))
  166. const readyAbort = new AbortController()
  167. readyAbort.abort(new Error('cancelled while ready'))
  168. await expect(preparations.borrow(readyId, () => Promise.resolve(ready), readyAbort.signal))
  169. .rejects.toThrow('cancelled while ready')
  170. await preparations.inspect(SessionId('borrow-eviction'), () => Promise.resolve(prepared('borrow-eviction')))
  171. expect(preparations.has(loadingId)).toBe(false)
  172. })
  173. it('makes borrowed lease disposal idempotent across ready, invalidated, and reserved entries', async () => {
  174. const preparations = new SessionPreparations<PreparedSource, string>(3)
  175. const ready = prepared('borrow-ready-release')
  176. const readyLease = await preparations.borrow(ready.session.id, () => Promise.resolve(ready))
  177. readyLease[Symbol.dispose]()
  178. readyLease[Symbol.dispose]()
  179. const invalidated = prepared('borrow-invalidated-release')
  180. const invalidatedLease = await preparations.borrow(
  181. invalidated.session.id,
  182. () => Promise.resolve(invalidated),
  183. )
  184. preparations.invalidate(invalidated.session.id)
  185. invalidatedLease[Symbol.dispose]()
  186. const reserved = prepared('borrow-reserved-release')
  187. const reservation = await preparations.reserve(
  188. reserved.session.id,
  189. () => Promise.resolve(reserved),
  190. committed,
  191. )
  192. expect(reservation).toBeDefined()
  193. const reservedLease = await preparations.borrow(
  194. reserved.session.id,
  195. () => Promise.resolve(prepared('unused')),
  196. )
  197. reservedLease[Symbol.dispose]()
  198. preparations.release(reservation!, false)
  199. })
  200. })
  201. describe('SessionPreparations reservation', () => {
  202. it('waits for an existing reservation, republishes the exact Session, and attaches once', async () => {
  203. const preparations = new SessionPreparations<PreparedSource, string>(2)
  204. const id = SessionId('reservation-wait')
  205. const source = prepared(id)
  206. const first = await preparations.reserve(id, () => Promise.resolve(source), committed)
  207. expect(first).toBeDefined()
  208. expect(preparations.reservationFor(source.session)).toBe(first)
  209. expect(() => preparations.reservationFor(Session.create(id))).toThrow(/cannot publish/)
  210. expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
  211. let secondSettled = false
  212. const secondPromise = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
  213. .then((reservation) => {
  214. secondSettled = true
  215. return reservation
  216. })
  217. await Promise.resolve()
  218. await Promise.resolve()
  219. await Promise.resolve()
  220. expect(secondSettled).toBe(false)
  221. preparations.release(first!, true)
  222. const second = await secondPromise
  223. expect(second?.source).toBe(source)
  224. preparations.attach(second!)
  225. expect(preparations.reservationFor(source.session)).toBeUndefined()
  226. expect(() => { preparations.attach(second!) }).toThrow(/no longer reserved/)
  227. preparations.discard(second!)
  228. preparations.release(second!, true)
  229. expect(() => { preparations.assertWritable(id) }).not.toThrow()
  230. })
  231. it('supports abortable reservation waits without cancelling the held reservation', async () => {
  232. const preparations = new SessionPreparations<PreparedSource, string>(1)
  233. const id = SessionId('abortable-reservation-wait')
  234. const first = await preparations.reserve(id, () => Promise.resolve(prepared(id)), committed)
  235. const controller = new AbortController()
  236. const reason = { kind: 'cancelled' }
  237. const waiting = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed, controller.signal)
  238. await Promise.resolve()
  239. await Promise.resolve()
  240. await Promise.resolve()
  241. controller.abort(reason)
  242. await expect(waiting).rejects.toBe(reason)
  243. expect(preparations.reservationFor(first!.source.session)).toBe(first)
  244. preparations.release(first!, false)
  245. expect(preparations.has(id)).toBe(false)
  246. })
  247. it('removes a failed commit and wakes another waiter as invalidated', async () => {
  248. const preparations = new SessionPreparations<PreparedSource, string>(1)
  249. const id = SessionId('failed-commit')
  250. const commitStarted = Promise.withResolvers<undefined>()
  251. const commitGate = Promise.withResolvers<{ source: PreparedSource; state: string }>()
  252. const source = prepared(id)
  253. const failure = new Error('commit failed')
  254. const first = preparations.reserve(id, () => Promise.resolve(source), () => {
  255. commitStarted.resolve(undefined)
  256. return commitGate.promise
  257. })
  258. await commitStarted.promise
  259. expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
  260. const second = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
  261. commitGate.reject(failure)
  262. await expect(first).rejects.toBe(failure)
  263. await expect(second).resolves.toBeUndefined()
  264. expect(preparations.has(id)).toBe(false)
  265. })
  266. it('returns a post-commit cancellation to the ready pool', async () => {
  267. const preparations = new SessionPreparations<PreparedSource, string>(1)
  268. const id = SessionId('post-commit-cancel')
  269. const source = prepared(id)
  270. const controller = new AbortController()
  271. const reason = new Error('cancel after commit')
  272. await expect(preparations.reserve(id, () => Promise.resolve(source), async (value) => {
  273. controller.abort(reason)
  274. return { source: value, state: value.label }
  275. }, controller.signal)).rejects.toBe(reason)
  276. expect(preparations.takeReady(id)).toBe(source)
  277. expect(preparations.takeReady(id)).toBeUndefined()
  278. })
  279. it('does not revive an invalidated commit after post-commit cancellation', async () => {
  280. const preparations = new SessionPreparations<PreparedSource, string>(1)
  281. const id = SessionId('invalidated-commit-cancel')
  282. const source = prepared(id)
  283. const commitStarted = Promise.withResolvers<undefined>()
  284. const commitGate = Promise.withResolvers<undefined>()
  285. const controller = new AbortController()
  286. const reason = new Error('cancel invalidated commit')
  287. const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
  288. commitStarted.resolve(undefined)
  289. await commitGate.promise
  290. return { source: value, state: value.label }
  291. }, controller.signal)
  292. await commitStarted.promise
  293. preparations.invalidate(id)
  294. controller.abort(reason)
  295. commitGate.resolve(undefined)
  296. await expect(reservation).rejects.toBe(reason)
  297. expect(preparations.has(id)).toBe(false)
  298. })
  299. it('does not reserve an entry invalidated while its commit succeeds', async () => {
  300. const preparations = new SessionPreparations<PreparedSource, string>(1)
  301. const id = SessionId('invalidated-successful-commit')
  302. const source = prepared(id)
  303. const commitStarted = Promise.withResolvers<undefined>()
  304. const commitGate = Promise.withResolvers<undefined>()
  305. const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
  306. commitStarted.resolve(undefined)
  307. await commitGate.promise
  308. return { source: value, state: value.label }
  309. })
  310. await commitStarted.promise
  311. preparations.invalidate(id)
  312. commitGate.resolve(undefined)
  313. await expect(reservation).resolves.toBeUndefined()
  314. expect(preparations.has(id)).toBe(false)
  315. })
  316. it('returns undefined when a load is invalidated before reservation', async () => {
  317. const preparations = new SessionPreparations<PreparedSource, string>(1)
  318. const id = SessionId('invalidated-reservation')
  319. const gate = Promise.withResolvers<PreparedSource>()
  320. const reservation = preparations.reserve(id, () => gate.promise, committed)
  321. preparations.invalidate(id)
  322. gate.resolve(prepared(id))
  323. await expect(reservation).resolves.toBeUndefined()
  324. })
  325. it('skips pending adoption and accepts a ready source exactly once', async () => {
  326. const preparations = new SessionPreparations<PreparedSource, string>(1)
  327. const id = SessionId('take-ready')
  328. const gate = Promise.withResolvers<PreparedSource>()
  329. const inspection = preparations.inspect(id, () => gate.promise)
  330. expect(preparations.takeReady(id)).toBeUndefined()
  331. const source = prepared(id)
  332. gate.resolve(source)
  333. await inspection
  334. expect(preparations.takeReady(id)).toBe(source)
  335. expect(preparations.takeReady(id)).toBeUndefined()
  336. })
  337. it('rejects publication while only an inspection exists', async () => {
  338. const preparations = new SessionPreparations<PreparedSource, string>(1)
  339. const source = prepared('inspection-publication')
  340. await preparations.inspect(source.session.id, () => Promise.resolve(source))
  341. expect(() => preparations.reservationFor(source.session)).toThrow(/cannot publish/)
  342. })
  343. })
  344. describe('observeQueuedAbort', () => {
  345. it('relays fulfillment and rejection exactly', async () => {
  346. const signal = new AbortController().signal
  347. await expect(observeQueuedAbort(Promise.resolve('value'), signal)).resolves.toBe('value')
  348. const failure = { kind: 'failed' }
  349. const rejected = Promise.withResolvers<never>()
  350. rejected.reject(failure)
  351. await expect(observeQueuedAbort(rejected.promise, signal)).rejects.toBe(failure)
  352. })
  353. it('rejects promptly with an exact abort reason and ignores later settlement', async () => {
  354. const operation = Promise.withResolvers<string>()
  355. const controller = new AbortController()
  356. const reason = { kind: 'aborted' }
  357. const observed = observeQueuedAbort(operation.promise, controller.signal)
  358. controller.abort(reason)
  359. await expect(observed).rejects.toBe(reason)
  360. operation.resolve('late')
  361. await Promise.resolve()
  362. })
  363. it('observes a pre-aborted signal through the default start predicate', async () => {
  364. const controller = new AbortController()
  365. controller.abort('pre-aborted')
  366. await expect(observeQueuedAbort(new Promise<never>(() => {}), controller.signal))
  367. .rejects.toBe('pre-aborted')
  368. })
  369. it('lets an operation that already started own cancellation settlement', async () => {
  370. const operation = Promise.withResolvers<string>()
  371. const controller = new AbortController()
  372. const observed = observeQueuedAbort(operation.promise, controller.signal, () => true)
  373. controller.abort(new Error('too late'))
  374. operation.resolve('owned')
  375. await expect(observed).resolves.toBe('owned')
  376. })
  377. })