office-cache.client.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. /** Controlled conversion completions pin shared cancellation, freshness, and bounded binary reuse. */
  2. import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
  3. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  4. import { expect, it, vi } from 'vitest'
  5. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  6. import type { SessionFile } from '../src/client/rpc.ts'
  7. import type { ReadOfficeDocument } from '../src/client/office/cache.ts'
  8. import { OfficePreviewCache } from '../src/client/office/cache.ts'
  9. const file = { sessionId: 's1' as SessionId, path: 'report.docx' }
  10. const result = (version = 'v1', text = 'pdf!', generation = 'renderer'): Awaited<ReturnType<ReadOfficeDocument>> => ({
  11. ok: true, value: { absolutePath: '/report.docx', version, offset: 0, eof: true, missingFonts: [], data: new TextEncoder().encode(text), generation: (generation as OfficeToPdfGeneration) },
  12. })
  13. function harness(entries = 2, bytes = 32, pending = 8, readers = 32) {
  14. const stat = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } })
  15. const convert = vi.fn<import('../src/client/office/cache.ts').ReadOfficeBytes>().mockResolvedValue(result())
  16. const generation = vi.fn().mockResolvedValue({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
  17. const cache = new OfficePreviewCache(stat, convert, entries, bytes, pending, readers, generation, () => new Error('busy'))
  18. return { stat, convert, cache, generation, read: (signal = new AbortController().signal) => cache.read(file, signal) }
  19. }
  20. it('shares a pending conversion between readers and reauthorizes cached reads', async () => {
  21. const h = harness()
  22. const started = Promise.withResolvers<AbortSignal>()
  23. const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  24. h.convert.mockImplementation((_file, signal) => { started.resolve(signal); return completed.promise })
  25. const background = new AbortController()
  26. const first = h.read(background.signal)
  27. const aborted = expect(first).rejects.toMatchObject({ name: 'AbortError' })
  28. const conversionSignal = await started.promise
  29. const second = h.read()
  30. await vi.waitFor(() => { expect(h.stat).toHaveBeenCalledTimes(2) })
  31. background.abort()
  32. await aborted
  33. expect(conversionSignal.aborted).toBe(false)
  34. completed.resolve(result())
  35. expect(await second).toEqual(result())
  36. expect(await h.read()).toEqual(result())
  37. expect(h.convert).toHaveBeenCalledOnce()
  38. expect(h.stat).toHaveBeenCalledTimes(3)
  39. await h.cache.dispose()
  40. })
  41. it('rejects stale or unauthorized reuse and never caches a declared conversion failure', async () => {
  42. const h = harness()
  43. await h.read()
  44. h.stat.mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v2' } })
  45. const failure = { ok: false, error: { message: 'failed' } } as Awaited<ReturnType<ReadOfficeDocument>>
  46. h.convert.mockResolvedValueOnce(failure).mockResolvedValue(result('v2'))
  47. expect(await h.read()).toEqual(failure)
  48. expect(await h.read()).toEqual(result('v2'))
  49. h.stat.mockResolvedValue(failure)
  50. expect(await h.read()).toEqual(failure)
  51. expect(h.convert).toHaveBeenCalledTimes(3)
  52. await h.cache.dispose()
  53. })
  54. it('discards a cancelled conversion that finishes late without replacing a newer result', async () => {
  55. const h = harness()
  56. const started = Promise.withResolvers<undefined>()
  57. const late = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  58. h.convert.mockImplementationOnce(() => { started.resolve(undefined); return late.promise })
  59. const caller = new AbortController()
  60. const pending = h.read(caller.signal)
  61. const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
  62. await started.promise
  63. caller.abort()
  64. await rejected
  65. h.convert.mockResolvedValue(result('v1', 'new'))
  66. await h.read()
  67. late.resolve(result('v1', 'old'))
  68. await late.promise
  69. expect(await h.read()).toEqual(result('v1', 'new'))
  70. await h.cache.dispose()
  71. })
  72. it('bounds retained entries and byte storage, and separates Session authorization scopes', async () => {
  73. const h = harness(1, 8)
  74. await h.read()
  75. await h.cache.read({ ...file, sessionId: 's2' as SessionId }, new AbortController().signal)
  76. await h.read()
  77. expect(h.convert).toHaveBeenCalledTimes(3)
  78. h.stat.mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'large' } })
  79. h.convert.mockResolvedValue(result('large', 'x'.repeat(10)))
  80. await h.read()
  81. await h.read()
  82. expect(h.convert).toHaveBeenCalledTimes(5)
  83. await h.cache.dispose()
  84. })
  85. it('does not retain a conversion whose source changed during conversion', async () => {
  86. const h = harness()
  87. h.convert.mockResolvedValue(result('v2'))
  88. await h.read()
  89. await h.read()
  90. expect(h.convert).toHaveBeenCalledTimes(2)
  91. await h.cache.dispose()
  92. })
  93. it('reuses the borrowed binary PDF at the exact byte budget without copying', async () => {
  94. const h = harness(2, 4)
  95. const converted = result()
  96. h.convert.mockResolvedValue(converted)
  97. try {
  98. expect(await h.read()).toBe(converted)
  99. const cached = await h.read()
  100. expect(cached).toBe(converted)
  101. if (!cached.ok || !converted.ok) throw new Error('Expected PDF bytes')
  102. expect(cached.value.data).toBe(converted.value.data)
  103. expect(cached.value.data.byteLength).toBe(4)
  104. expect(h.convert).toHaveBeenCalledOnce()
  105. } finally { await h.cache.dispose() }
  106. })
  107. it('does not retain a different canonical source even when its version token matches', async () => {
  108. const h = harness()
  109. h.convert.mockResolvedValue({ ok: true, value: {
  110. absolutePath: '/replacement.docx', version: 'v1', offset: 0, eof: true, missingFonts: [], generation: 'renderer' as OfficeToPdfGeneration, data: new Uint8Array([1]),
  111. } })
  112. try {
  113. await h.read()
  114. await h.read()
  115. expect(h.convert).toHaveBeenCalledTimes(2)
  116. } finally { await h.cache.dispose() }
  117. })
  118. it('cancels before source authorization and after a delayed stat without starting conversion', async () => {
  119. const h = harness()
  120. try {
  121. await expect(h.read(AbortSignal.abort())).rejects.toMatchObject({ name: 'AbortError' })
  122. expect(h.stat).not.toHaveBeenCalled()
  123. const metadata = Promise.withResolvers<Awaited<ReturnType<typeof h.stat>>>()
  124. h.stat.mockReturnValue(metadata.promise)
  125. const caller = new AbortController()
  126. const pending = h.read(caller.signal)
  127. const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
  128. caller.abort()
  129. metadata.resolve({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } })
  130. await rejected
  131. expect(h.convert).not.toHaveBeenCalled()
  132. } finally { await h.cache.dispose() }
  133. })
  134. it.each([new Error('transport'), 'transport'])('retries rejected conversions (%s) and waits for cancelled Host work when disposed', async (failure) => {
  135. const h = harness()
  136. const started = Promise.withResolvers<AbortSignal>()
  137. const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  138. try {
  139. h.convert.mockRejectedValueOnce(failure)
  140. await expect(h.read()).rejects.toMatchObject(failure instanceof Error ? { message: 'transport' } : {
  141. message: 'Office preview failed', cause: failure,
  142. })
  143. h.convert.mockImplementation((_file, signal) => { started.resolve(signal); return completed.promise })
  144. const pending = h.read()
  145. const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
  146. const signal = await started.promise
  147. let disposed = false
  148. const disposing = h.cache.dispose().then(() => { disposed = true })
  149. await rejected
  150. expect(signal.aborted).toBe(true)
  151. expect(disposed).toBe(false)
  152. completed.resolve(result())
  153. await disposing
  154. await expect(h.read()).rejects.toMatchObject({ name: 'AbortError' })
  155. } finally {
  156. completed.resolve(result())
  157. await h.cache.dispose()
  158. }
  159. })
  160. it('preserves an AbortSignal cancellation reason while waiting for the shared conversion to settle', async () => {
  161. const h = harness()
  162. const caller = new AbortController()
  163. const started = Promise.withResolvers<undefined>()
  164. const completion = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  165. h.convert.mockImplementation(() => { started.resolve(undefined); return completion.promise })
  166. const pending = h.read(caller.signal)
  167. const rejected = expect(pending).rejects.toMatchObject({ message: 'Office preview cancelled', cause: 'left document' })
  168. try {
  169. await started.promise
  170. caller.abort('left document')
  171. await rejected
  172. } finally {
  173. completion.resolve(result())
  174. await h.cache.dispose()
  175. }
  176. })
  177. it('clears Client reuse when the Host renderer generation changes', async () => {
  178. const h = harness()
  179. try {
  180. await h.read()
  181. h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
  182. h.convert.mockResolvedValue(result('v1', 'pdf!', 'replacement'))
  183. await h.read()
  184. expect(h.convert).toHaveBeenCalledTimes(2)
  185. await h.read()
  186. expect(h.convert).toHaveBeenCalledTimes(2)
  187. } finally { await h.cache.dispose() }
  188. })
  189. it('bounds metadata readers and unsettled conversion RPCs including cancellation teardown', async () => {
  190. const h = harness(2, 32, 1, 2)
  191. const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  192. h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise })
  193. const caller = new AbortController()
  194. const first = expect(h.read(caller.signal)).rejects.toMatchObject({ name: 'AbortError' })
  195. await entered.promise
  196. caller.abort()
  197. await first
  198. try {
  199. await expect(h.read()).rejects.toThrow('busy')
  200. expect(h.convert).toHaveBeenCalledOnce()
  201. } finally { completed.resolve(result()); await h.cache.dispose() }
  202. })
  203. it('sends foreground intent to the Host when joining an in-flight prewarm', async () => {
  204. const h = harness()
  205. const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  206. h.convert.mockImplementation(() => { entered.resolve(undefined); return completed.promise })
  207. const prewarm = h.cache.read(file, new AbortController().signal, 'background')
  208. await entered.promise
  209. const foreground = h.cache.read(file, new AbortController().signal, 'foreground')
  210. try {
  211. await vi.waitFor(() => { expect(h.convert).toHaveBeenCalledTimes(2) })
  212. expect(h.convert.mock.calls.map(call => call[2])).toEqual(['background', 'foreground'])
  213. } finally { completed.resolve(result()); await Promise.all([prewarm, foreground]); await h.cache.dispose() }
  214. })
  215. it('counts unresolved renderer metadata against the reader limit and joins it on disposal', async () => {
  216. const h = harness(2, 32, 8, 1)
  217. const completed = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
  218. h.generation.mockReturnValueOnce(completed.promise)
  219. const first = expect(h.read()).rejects.toMatchObject({ name: 'AbortError' })
  220. await expect(h.read()).rejects.toThrow('busy')
  221. expect(h.generation).toHaveBeenCalledOnce()
  222. let disposed = false
  223. const closing = h.cache.dispose().then(() => { disposed = true })
  224. expect(disposed).toBe(false)
  225. completed.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
  226. await first
  227. await closing
  228. expect(h.stat).not.toHaveBeenCalled()
  229. })
  230. it('passes renderer discovery failures through before source authorization and allows retry', async () => {
  231. const h = harness()
  232. const failure = { ok: false, error: new RemoteError('document-render/failed', 'Renderer unavailable', { reason: 'unavailable' }) }
  233. try {
  234. h.generation.mockResolvedValueOnce(failure)
  235. expect(await h.read()).toBe(failure)
  236. expect(h.stat).not.toHaveBeenCalled()
  237. expect(h.convert).not.toHaveBeenCalled()
  238. expect(await h.read()).toEqual(result())
  239. expect(h.convert).toHaveBeenCalledOnce()
  240. } finally { await h.cache.dispose() }
  241. })
  242. it('restarts a stale generation query without invalidating the current renderer', async () => {
  243. const h = harness()
  244. const current = { ok: true as const, value: ('current' as OfficeToPdfGeneration) }
  245. const stale = { ok: true as const, value: ('stale' as OfficeToPdfGeneration) }
  246. const converted = result('v1', 'pdf!', 'current')
  247. h.convert.mockResolvedValue(converted)
  248. const firstQuery = Promise.withResolvers<typeof current>()
  249. const middleQuery = Promise.withResolvers<typeof current>()
  250. h.generation.mockReturnValueOnce(firstQuery.promise).mockReturnValueOnce(middleQuery.promise).mockResolvedValue(current)
  251. const earlier = h.read()
  252. const outdated = h.read()
  253. const restarted = expect(outdated).resolves.toEqual(converted)
  254. try {
  255. expect(await h.read()).toEqual(converted)
  256. firstQuery.resolve(current)
  257. expect(await earlier).toEqual(converted)
  258. middleQuery.resolve(stale)
  259. await restarted
  260. expect(await h.read()).toEqual(converted)
  261. expect(h.stat).toHaveBeenCalledTimes(4)
  262. expect(h.convert).toHaveBeenCalledOnce()
  263. } finally {
  264. firstQuery.resolve(current)
  265. middleQuery.resolve(stale)
  266. await h.cache.dispose()
  267. }
  268. })
  269. it('restarts a pending conversion when a newer renderer generation is accepted', async () => {
  270. const h = harness()
  271. const entered = Promise.withResolvers<AbortSignal>()
  272. const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  273. h.convert.mockImplementationOnce((_file, signal) => {
  274. signal.addEventListener('abort', () => { completed.reject(signal.reason) }, { once: true })
  275. entered.resolve(signal)
  276. return completed.promise
  277. })
  278. const previous = h.read()
  279. const converted = result('v1', 'pdf!', 'replacement')
  280. const restarted = expect(previous).resolves.toEqual(converted)
  281. try {
  282. const signal = await entered.promise
  283. h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
  284. h.convert.mockResolvedValue(converted)
  285. expect(await h.read()).toEqual(converted)
  286. await restarted
  287. expect(signal.aborted).toBe(true)
  288. expect(h.convert).toHaveBeenCalledTimes(2)
  289. await h.read()
  290. expect(h.convert).toHaveBeenCalledTimes(2)
  291. } finally {
  292. completed.resolve(result())
  293. await h.cache.dispose()
  294. }
  295. })
  296. it('reauthorizes source metadata that completes after the renderer generation changes', async () => {
  297. const h = harness()
  298. const entered = Promise.withResolvers<undefined>()
  299. const metadata = { ok: true, value: { absolutePath: '/report.docx', version: 'v1' } }
  300. const completed = Promise.withResolvers<typeof metadata>()
  301. h.stat.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise })
  302. const previous = h.read()
  303. const converted = result('v1', 'pdf!', 'replacement')
  304. const restarted = expect(previous).resolves.toEqual(converted)
  305. try {
  306. await entered.promise
  307. h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
  308. h.convert.mockResolvedValue(converted)
  309. expect(await h.read()).toEqual(converted)
  310. completed.resolve(metadata)
  311. await restarted
  312. expect(h.convert).toHaveBeenCalledOnce()
  313. } finally {
  314. completed.resolve(metadata)
  315. await h.cache.dispose()
  316. }
  317. })
  318. it.each([[1, 32], [8, 1]])('leaves foreground admission available when pending/readers are %i/%i', async (pending, readers) => {
  319. const h = harness(2, 32, pending, readers)
  320. try {
  321. await expect(h.cache.read(file, new AbortController().signal, 'background')).rejects.toThrow('busy')
  322. expect(h.convert).not.toHaveBeenCalled()
  323. expect(await h.read()).toEqual(result())
  324. } finally { await h.cache.dispose() }
  325. })
  326. it.each([[2, 32], [8, 2]])('reserves the final pending/reader slot for foreground promotion with %i/%i', async (pending, readers) => {
  327. const h = harness(2, 32, pending, readers)
  328. h.stat.mockImplementation(async (requested: SessionFile) => ({ ok: true, value: { absolutePath: `/${requested.path}`, version: 'v1' } }))
  329. const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
  330. h.convert.mockImplementation(() => { entered.resolve(undefined); return complete.promise })
  331. const prewarm = h.cache.read(file, new AbortController().signal, 'background')
  332. await entered.promise
  333. let foreground: ReturnType<typeof h.read> | undefined
  334. try {
  335. await expect(h.cache.read({ ...file, path: 'other.docx' }, new AbortController().signal, 'background')).rejects.toThrow('busy')
  336. foreground = h.read()
  337. await vi.waitFor(() => { expect(h.convert).toHaveBeenCalledTimes(2) })
  338. expect(h.convert.mock.calls.map(call => call[2])).toEqual(['background', 'foreground'])
  339. } finally { complete.resolve(result()); await Promise.all([prewarm, foreground]); await h.cache.dispose() }
  340. })
  341. it('reports repeated generation supersession through the localized capacity failure', async () => {
  342. const h = harness()
  343. const first = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
  344. const retry = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
  345. h.generation.mockReturnValueOnce(first.promise)
  346. const work = expect(h.read()).rejects.toThrow('busy')
  347. try {
  348. await h.read()
  349. h.generation.mockReturnValueOnce(retry.promise)
  350. first.resolve({ ok: true, value: ('obsolete' as OfficeToPdfGeneration) })
  351. await vi.waitFor(() => { expect(h.generation).toHaveBeenCalledTimes(3) })
  352. h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
  353. await h.read()
  354. retry.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
  355. await work
  356. expect(h.generation).toHaveBeenCalledTimes(4)
  357. } finally {
  358. first.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
  359. retry.resolve({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
  360. await h.cache.dispose()
  361. }
  362. })
  363. it('does not retain a PDF returned by a generation newer than the preceding metadata query', async () => {
  364. const h = harness()
  365. const converted = result('v1', 'pdf!', 'replacement')
  366. h.convert.mockResolvedValue(converted)
  367. try {
  368. expect(await h.read()).toBe(converted)
  369. expect(await h.read()).toBe(converted)
  370. expect(h.convert).toHaveBeenCalledTimes(2)
  371. } finally { await h.cache.dispose() }
  372. })