instance.test.ts 7.8 KB

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