host-path.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. /** Host-resolved file identities across pending stats, retries, and disposal. */
  2. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  3. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  4. import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
  5. import { describe, expect, it, onTestFinished } from 'vitest'
  6. import { ChangeFeed } from '../src/client/change-feed.ts'
  7. import { createFileResourceProvider } from '../src/client/provider.ts'
  8. import { FakeRemote, settle } from './fake-remote.client.ts'
  9. const SESSION = 'host-only' as SessionId
  10. const RELATIVE = 'linked/a b.txt'
  11. const ADDRESS = sessionFileAddress(SESSION, RELATIVE)
  12. const CANONICAL = '/host/canonical/a b.txt'
  13. function harness() {
  14. const remote = new FakeRemote()
  15. const changes = new ChangeFeed(remote)
  16. const provider = createFileResourceProvider(remote, changes)
  17. const open = (address = ADDRESS) => {
  18. const controller = new AbortController()
  19. const iterator = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
  20. onTestFinished(async () => {
  21. controller.abort()
  22. for (const request of remote.stats) {
  23. request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) })
  24. }
  25. await iterator.return?.()
  26. await changes.settle()
  27. })
  28. return { iterator, controller }
  29. }
  30. return { remote, changes, open }
  31. }
  32. describe('Host-resolved file paths', () => {
  33. it('accepts Host ready before submitting the unmodified relative path without a Client Session summary', async () => {
  34. const { remote, open } = harness()
  35. const { iterator } = open()
  36. const first = iterator.next()
  37. const request = await remote.waitForStat(0)
  38. expect(remote.calls).toEqual(['changes', 'accept', 'stat'])
  39. expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE })
  40. request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
  41. await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } } })
  42. })
  43. it('does not stat an opened changes stream until the Host acknowledges its subscription', async () => {
  44. const { remote, open } = harness()
  45. remote.autoReady = false
  46. const { iterator } = open()
  47. const first = iterator.next()
  48. const { source } = await remote.waitForChanges(0)
  49. await settle()
  50. expect(remote.calls).toEqual(['changes'])
  51. expect(remote.stats).toEqual([])
  52. await source.deliver({ kind: 'ready' })
  53. const request = await remote.waitForStat(0)
  54. expect(remote.calls).toEqual(['changes', 'accept', 'stat'])
  55. request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
  56. await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } } })
  57. })
  58. it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => {
  59. const { remote, changes, open } = harness()
  60. remote.autoReady = false
  61. const { iterator, controller } = open()
  62. const first = iterator.next()
  63. const { source } = await remote.waitForChanges(0)
  64. switch (ending) {
  65. case 'abort': controller.abort(); break
  66. case 'end': source.end(); break
  67. case 'failure': source.fail(new Error('workspace root unavailable')); break
  68. default: throw new Error(`Unexpected stream ending: ${ending satisfies never}`)
  69. }
  70. await expect(first).resolves.toEqual({ done: true, value: undefined })
  71. await changes.settle()
  72. expect(remote.calls).toEqual(['changes'])
  73. expect(remote.stats).toEqual([])
  74. expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
  75. expect(source.aborted).toBe(true)
  76. })
  77. it('accepts another generation ready without resetting metadata or re-statting existing files', async () => {
  78. const { remote, open } = harness()
  79. const { iterator } = open()
  80. const first = iterator.next()
  81. const request = await remote.waitForStat(0)
  82. request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
  83. await first
  84. const next = iterator.next()
  85. const source = remote.opened[0]!.source
  86. await source.deliver({ kind: 'ready' })
  87. await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
  88. expect(remote.stats).toHaveLength(1)
  89. expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept'])
  90. await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } })
  91. })
  92. it.each([
  93. ['relative', ADDRESS, RELATIVE],
  94. ['absolute', sessionFileAddress(SESSION, '/shortcut/a.txt'), '/shortcut/a.txt'],
  95. ])('filters queued and live %s changes using the Host canonical path, not the input path', async (_, address, path) => {
  96. const { remote, open } = harness()
  97. const { iterator } = open(address)
  98. const first = iterator.next()
  99. const request = await remote.waitForStat(0)
  100. expect(request).toMatchObject({ sessionId: SESSION, path })
  101. const source = remote.opened[0]!.source
  102. // Delivery is acknowledged while stat is still unresolved, before a key can be bound.
  103. await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-before-stat' } })
  104. await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
  105. request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
  106. await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } })
  107. await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } })
  108. await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } })
  109. await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
  110. await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } })
  111. expect(remote.stats).toHaveLength(1)
  112. })
  113. it.each(['write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async () => {
  114. const { remote, open } = harness()
  115. const { iterator } = open()
  116. const first = iterator.next()
  117. const request = await remote.waitForStat(0)
  118. const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE })
  119. request.resolve({ ok: false, error })
  120. await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
  121. const source = remote.opened[0]!.source
  122. const retried = iterator.next()
  123. await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } })
  124. const retry = await remote.waitForStat(1)
  125. expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE })
  126. await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other' } })
  127. await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v3' } })
  128. retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } })
  129. await expect(retried).resolves.toEqual({
  130. done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } },
  131. })
  132. await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3' } } })
  133. expect(remote.stats).toHaveLength(2)
  134. })
  135. it('binds a changed canonical path after disappearance before filtering writes received during that stat', async () => {
  136. const { remote, open } = harness()
  137. const { iterator } = open()
  138. const first = iterator.next()
  139. const request = await remote.waitForStat(0)
  140. request.resolve({ ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } })
  141. await first
  142. const reloaded = iterator.next()
  143. await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', absent: true } })
  144. const retry = await remote.waitForStat(1)
  145. const source = remote.opened[0]!.source
  146. await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', version: 'old-target-write' } })
  147. await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
  148. retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1' } })
  149. await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
  150. await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } })
  151. expect(remote.stats).toHaveLength(2)
  152. })
  153. it('drops a late stat after abort and waits for the session stream disposal', async () => {
  154. const { remote, changes, open } = harness()
  155. const { iterator, controller } = open()
  156. const first = iterator.next()
  157. const request = await remote.waitForStat(0)
  158. await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
  159. const gate = Promise.withResolvers<undefined>()
  160. remote.disposeGate = gate.promise
  161. try {
  162. controller.abort()
  163. request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
  164. await expect(first).resolves.toEqual({ done: true, value: undefined })
  165. expect(remote.opened[0]!.source.aborted).toBe(true)
  166. let settled = false
  167. const closing = changes.settle().then(() => { settled = true })
  168. await settle()
  169. expect(settled).toBe(false)
  170. gate.resolve(undefined)
  171. await closing
  172. expect(settled).toBe(true)
  173. expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
  174. } finally {
  175. gate.resolve(undefined)
  176. }
  177. })
  178. it('aborts while waiting for a predecessor to close without sending a stat', async () => {
  179. const { remote, changes, open } = harness()
  180. const previous = open()
  181. const first = previous.iterator.next()
  182. const request = await remote.waitForStat(0)
  183. request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
  184. await first
  185. const gate = Promise.withResolvers<undefined>()
  186. remote.disposeGate = gate.promise
  187. try {
  188. previous.controller.abort()
  189. const next = open()
  190. const pending = next.iterator.next()
  191. next.controller.abort()
  192. await expect(pending).resolves.toEqual({ done: true, value: undefined })
  193. expect(remote.stats).toHaveLength(1)
  194. gate.resolve(undefined)
  195. await changes.settle()
  196. expect(remote.disposed).toHaveLength(2)
  197. } finally {
  198. gate.resolve(undefined)
  199. }
  200. })
  201. it.each([true, false])('releases an unconsumed notification follower after the first stat (success: %s)', async (ok) => {
  202. const { remote, changes, open } = harness()
  203. const { iterator } = open()
  204. const first = iterator.next()
  205. const request = await remote.waitForStat(0)
  206. request.resolve(ok
  207. ? { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }
  208. : { ok: false, error: new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) })
  209. await first
  210. await iterator.return?.()
  211. await changes.settle()
  212. expect(remote.opened[0]!.source.aborted).toBe(true)
  213. expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
  214. })
  215. })