instance.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { describe, expect } from "bun:test"
  2. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  3. import { Deferred, Effect, Fiber, Layer, Queue } from "effect"
  4. import { InstanceRef } from "../../src/effect/instance-ref"
  5. import { registerDisposer } from "../../src/effect/instance-registry"
  6. import { InstanceBootstrap } from "../../src/project/bootstrap-service"
  7. import { Instance } from "../../src/project/instance"
  8. import { InstanceStore } from "../../src/project/instance-store"
  9. import { TestInstance, tmpdirScoped } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. let bootstrapRun: Effect.Effect<void> = Effect.void
  12. const noopBootstrap = Layer.succeed(
  13. InstanceBootstrap.Service,
  14. InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }),
  15. )
  16. const it = testEffect(
  17. Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)),
  18. )
  19. const setBootstrap = (run: Effect.Effect<void>) =>
  20. Effect.acquireRelease(
  21. Effect.sync(() => {
  22. bootstrapRun = run
  23. }),
  24. () =>
  25. Effect.sync(() => {
  26. bootstrapRun = Effect.void
  27. }),
  28. )
  29. const registerDisposerScoped = (disposer: (directory: string) => Promise<void>) =>
  30. Effect.acquireRelease(Effect.sync(() => registerDisposer(disposer)), (off) => Effect.sync(off))
  31. describe("InstanceStore", () => {
  32. it.live("loads instance context without installing ALS for the caller", () =>
  33. Effect.gen(function* () {
  34. const dir = yield* tmpdirScoped({ git: true })
  35. const store = yield* InstanceStore.Service
  36. const ctx = yield* store.load({ directory: dir })
  37. expect(ctx.directory).toBe(dir)
  38. expect(ctx.worktree).toBe(dir)
  39. expect(() => Instance.current).toThrow()
  40. }),
  41. )
  42. it.live("runs bootstrap with InstanceRef provided", () =>
  43. Effect.gen(function* () {
  44. const dir = yield* tmpdirScoped({ git: true })
  45. const store = yield* InstanceStore.Service
  46. let initializedDirectory: string | undefined
  47. yield* setBootstrap(
  48. Effect.gen(function* () {
  49. initializedDirectory = (yield* InstanceRef)?.directory
  50. }),
  51. )
  52. yield* store.load({ directory: dir })
  53. expect(initializedDirectory).toBe(dir)
  54. expect(() => Instance.current).toThrow()
  55. }),
  56. )
  57. it.live("caches loaded instance context by directory", () =>
  58. Effect.gen(function* () {
  59. const dir = yield* tmpdirScoped({ git: true })
  60. const store = yield* InstanceStore.Service
  61. let initialized = 0
  62. yield* setBootstrap(
  63. Effect.sync(() => {
  64. initialized++
  65. }),
  66. )
  67. const first = yield* store.load({ directory: dir })
  68. const second = yield* store.load({ directory: dir })
  69. expect(second).toBe(first)
  70. expect(initialized).toBe(1)
  71. }),
  72. )
  73. it.live("dedupes concurrent loads while init is in flight", () =>
  74. Effect.gen(function* () {
  75. const dir = yield* tmpdirScoped({ git: true })
  76. const store = yield* InstanceStore.Service
  77. const started = yield* Deferred.make<void>()
  78. const release = yield* Deferred.make<void>()
  79. let initialized = 0
  80. yield* setBootstrap(
  81. Effect.gen(function* () {
  82. initialized++
  83. yield* Deferred.succeed(started, undefined)
  84. yield* Deferred.await(release)
  85. }),
  86. )
  87. const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
  88. yield* Deferred.await(started)
  89. yield* setBootstrap(
  90. Effect.sync(() => {
  91. initialized++
  92. }),
  93. )
  94. const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped)
  95. expect(initialized).toBe(1)
  96. yield* Deferred.succeed(release, undefined)
  97. const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
  98. expect(secondCtx).toBe(firstCtx)
  99. expect(initialized).toBe(1)
  100. }),
  101. )
  102. it.live("removes failed loads from the cache", () =>
  103. Effect.gen(function* () {
  104. const dir = yield* tmpdirScoped({ git: true })
  105. const store = yield* InstanceStore.Service
  106. let attempts = 0
  107. yield* setBootstrap(
  108. Effect.sync(() => {
  109. attempts++
  110. throw new Error("init failed")
  111. }),
  112. )
  113. const failed = yield* store.load({ directory: dir }).pipe(
  114. Effect.as(false),
  115. Effect.catchCause(() => Effect.succeed(true)),
  116. )
  117. expect(failed).toBe(true)
  118. yield* setBootstrap(
  119. Effect.sync(() => {
  120. attempts++
  121. }),
  122. )
  123. const ctx = yield* store.load({ directory: dir })
  124. expect(ctx.directory).toBe(dir)
  125. expect(attempts).toBe(2)
  126. }),
  127. )
  128. it.live("reload replaces the cached context", () =>
  129. Effect.gen(function* () {
  130. const dir = yield* tmpdirScoped({ git: true })
  131. const store = yield* InstanceStore.Service
  132. const first = yield* store.load({ directory: dir })
  133. const second = yield* store.reload({ directory: dir })
  134. const cached = yield* store.load({ directory: dir })
  135. expect(second).not.toBe(first)
  136. expect(cached).toBe(second)
  137. }),
  138. )
  139. it.live("stale dispose does not delete an in-flight reload", () =>
  140. Effect.gen(function* () {
  141. const dir = yield* tmpdirScoped({ git: true })
  142. const store = yield* InstanceStore.Service
  143. const reloading = yield* Deferred.make<void>()
  144. const releaseReload = yield* Deferred.make<void>()
  145. const disposed: Array<string> = []
  146. yield* registerDisposerScoped(async (directory) => {
  147. disposed.push(directory)
  148. })
  149. const first = yield* store.load({ directory: dir })
  150. yield* setBootstrap(
  151. Effect.gen(function* () {
  152. yield* Deferred.succeed(reloading, undefined)
  153. yield* Deferred.await(releaseReload)
  154. }),
  155. )
  156. const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped)
  157. yield* Deferred.await(reloading)
  158. const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
  159. yield* Deferred.succeed(releaseReload, undefined)
  160. const second = yield* Fiber.join(reload)
  161. yield* Fiber.join(staleDispose)
  162. expect(disposed).toEqual([dir])
  163. expect(yield* store.load({ directory: dir })).toBe(second)
  164. }),
  165. )
  166. it.live("dedupes concurrent disposeAll calls", () =>
  167. Effect.gen(function* () {
  168. const dir = yield* tmpdirScoped({ git: true })
  169. const store = yield* InstanceStore.Service
  170. const disposing = yield* Deferred.make<void>()
  171. const releaseDispose = yield* Queue.unbounded<() => void>()
  172. const disposed: Array<string> = []
  173. yield* registerDisposerScoped((directory) => {
  174. disposed.push(directory)
  175. Deferred.doneUnsafe(disposing, Effect.void)
  176. return new Promise<void>((resolve) => {
  177. Queue.offerUnsafe(releaseDispose, resolve)
  178. })
  179. })
  180. yield* store.load({ directory: dir })
  181. const first = yield* store.disposeAll().pipe(Effect.forkScoped)
  182. yield* Deferred.await(disposing)
  183. const release = yield* Queue.take(releaseDispose)
  184. const second = yield* store.disposeAll().pipe(Effect.forkScoped)
  185. expect(disposed).toEqual([dir])
  186. yield* Effect.sync(release)
  187. yield* Effect.all([Fiber.join(first), Fiber.join(second)])
  188. expect(disposed).toEqual([dir])
  189. }),
  190. )
  191. it.live("re-arms disposeAll after completion", () =>
  192. Effect.gen(function* () {
  193. const dir1 = yield* tmpdirScoped({ git: true })
  194. const dir2 = yield* tmpdirScoped({ git: true })
  195. const store = yield* InstanceStore.Service
  196. const disposed: Array<string> = []
  197. yield* registerDisposerScoped(async (directory) => {
  198. disposed.push(directory)
  199. })
  200. yield* store.load({ directory: dir1 })
  201. yield* store.disposeAll()
  202. expect(disposed).toEqual([dir1])
  203. yield* store.load({ directory: dir2 })
  204. yield* store.disposeAll()
  205. expect(disposed).toEqual([dir1, dir2])
  206. }),
  207. )
  208. it.instance(
  209. "provides legacy Promise callers with instance ALS",
  210. () =>
  211. Effect.gen(function* () {
  212. const test = yield* TestInstance
  213. const ctx = yield* InstanceRef
  214. if (!ctx) throw new Error("InstanceRef not provided")
  215. const directory = yield* Effect.promise(() => Promise.resolve(Instance.restore(ctx, () => Instance.directory)))
  216. expect(directory).toBe(test.directory)
  217. expect(() => Instance.current).toThrow()
  218. }),
  219. { git: true },
  220. )
  221. })