instance-state.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import { expect } from "bun:test"
  2. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  3. import { $ } from "bun"
  4. import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
  5. import { InstanceState } from "@/effect/instance-state"
  6. import {
  7. disposeAllInstancesEffect,
  8. provideInstanceEffect,
  9. reloadInstance,
  10. testInstanceStoreLayer,
  11. tmpdirScoped,
  12. } from "../fixture/fixture"
  13. import { testEffect } from "../lib/effect"
  14. const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
  15. const access = <A, E>(state: InstanceState.InstanceState<A, E>, dir: string) =>
  16. InstanceState.get(state).pipe(provideInstanceEffect(dir))
  17. const tmpdirGitScoped = Effect.gen(function* () {
  18. const dir = yield* tmpdirScoped({ git: true })
  19. yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet())
  20. return dir
  21. })
  22. it.live("InstanceState caches values per directory", () =>
  23. Effect.gen(function* () {
  24. const dir = yield* tmpdirScoped()
  25. let n = 0
  26. const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
  27. const a = yield* access(state, dir)
  28. const b = yield* access(state, dir)
  29. expect(a).toBe(b)
  30. expect(n).toBe(1)
  31. }),
  32. )
  33. it.live("InstanceState isolates directories", () =>
  34. Effect.gen(function* () {
  35. const one = yield* tmpdirScoped()
  36. const two = yield* tmpdirScoped()
  37. let n = 0
  38. const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
  39. const a = yield* access(state, one)
  40. const b = yield* access(state, two)
  41. const c = yield* access(state, one)
  42. expect(a).toBe(c)
  43. expect(a).not.toBe(b)
  44. expect(n).toBe(2)
  45. }),
  46. )
  47. it.live("InstanceState invalidates on reload", () =>
  48. Effect.gen(function* () {
  49. const dir = yield* tmpdirScoped()
  50. const seen: string[] = []
  51. let n = 0
  52. const state = yield* InstanceState.make(() =>
  53. Effect.acquireRelease(
  54. Effect.sync(() => ({ n: ++n })),
  55. (value) =>
  56. Effect.sync(() => {
  57. seen.push(String(value.n))
  58. }),
  59. ),
  60. )
  61. const a = yield* access(state, dir)
  62. yield* reloadInstance({ directory: dir })
  63. const b = yield* access(state, dir)
  64. expect(a).not.toBe(b)
  65. expect(seen).toEqual(["1"])
  66. }),
  67. )
  68. it.live("InstanceState invalidates on disposeAll", () =>
  69. Effect.gen(function* () {
  70. const one = yield* tmpdirScoped()
  71. const two = yield* tmpdirScoped()
  72. const seen: string[] = []
  73. const state = yield* InstanceState.make((ctx) =>
  74. Effect.acquireRelease(
  75. Effect.sync(() => ({ dir: ctx.directory })),
  76. (value) =>
  77. Effect.sync(() => {
  78. seen.push(value.dir)
  79. }),
  80. ),
  81. )
  82. yield* access(state, one)
  83. yield* access(state, two)
  84. yield* disposeAllInstancesEffect
  85. expect(seen.sort()).toEqual([one, two].sort())
  86. }),
  87. )
  88. it.live("InstanceState.get reads the current directory lazily", () =>
  89. Effect.gen(function* () {
  90. const one = yield* tmpdirScoped()
  91. const two = yield* tmpdirScoped()
  92. interface Api {
  93. readonly get: () => Effect.Effect<string>
  94. }
  95. class Test extends Context.Service<Test, Api>()("@test/InstanceStateLazy") {
  96. static readonly layer = Layer.effect(
  97. Test,
  98. Effect.gen(function* () {
  99. const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
  100. const get = InstanceState.get(state)
  101. return Test.of({
  102. get: Effect.fn("Test.get")(function* () {
  103. return yield* get
  104. }),
  105. })
  106. }),
  107. )
  108. }
  109. yield* Effect.gen(function* () {
  110. const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
  111. const b = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))
  112. expect(a).toBe(one)
  113. expect(b).toBe(two)
  114. }).pipe(Effect.provide(Test.layer))
  115. }),
  116. )
  117. it.live("InstanceState preserves directory across async boundaries", () =>
  118. Effect.gen(function* () {
  119. const one = yield* tmpdirGitScoped
  120. const two = yield* tmpdirGitScoped
  121. const three = yield* tmpdirGitScoped
  122. interface Api {
  123. readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
  124. }
  125. class Test extends Context.Service<Test, Api>()("@test/InstanceStateAsync") {
  126. static readonly layer = Layer.effect(
  127. Test,
  128. Effect.gen(function* () {
  129. const state = yield* InstanceState.make((ctx) =>
  130. Effect.sync(() => ({
  131. directory: ctx.directory,
  132. worktree: ctx.worktree,
  133. project: ctx.project.id,
  134. })),
  135. )
  136. return Test.of({
  137. get: Effect.fn("Test.get")(function* () {
  138. yield* Effect.sleep(Duration.millis(1))
  139. yield* Effect.sleep(Duration.millis(1))
  140. for (let i = 0; i < 100; i++) {
  141. yield* Effect.yieldNow
  142. }
  143. for (let i = 0; i < 100; i++) {
  144. yield* Effect.promise(() => Promise.resolve())
  145. }
  146. yield* Effect.sleep(Duration.millis(2))
  147. yield* Effect.sleep(Duration.millis(1))
  148. return yield* InstanceState.get(state)
  149. }),
  150. })
  151. }),
  152. )
  153. }
  154. yield* Effect.gen(function* () {
  155. const [a, b, c] = yield* Effect.all(
  156. [one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))),
  157. { concurrency: "unbounded" },
  158. )
  159. expect(a).toEqual({ directory: one, worktree: one, project: a.project })
  160. expect(b).toEqual({ directory: two, worktree: two, project: b.project })
  161. expect(c).toEqual({ directory: three, worktree: three, project: c.project })
  162. expect(a.project).not.toBe(b.project)
  163. expect(a.project).not.toBe(c.project)
  164. expect(b.project).not.toBe(c.project)
  165. }).pipe(Effect.provide(Test.layer))
  166. }),
  167. )
  168. it.live("InstanceState survives high-contention concurrent access", () =>
  169. Effect.gen(function* () {
  170. const dirs = yield* Effect.all(
  171. Array.from({ length: 20 }, () => tmpdirScoped()),
  172. { concurrency: "unbounded" },
  173. )
  174. interface Api {
  175. readonly get: () => Effect.Effect<string>
  176. }
  177. class Test extends Context.Service<Test, Api>()("@test/HighContention") {
  178. static readonly layer = Layer.effect(
  179. Test,
  180. Effect.gen(function* () {
  181. const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
  182. return Test.of({
  183. get: Effect.fn("Test.get")(function* () {
  184. for (let i = 0; i < 10; i++) {
  185. yield* Effect.sleep(Duration.millis(Math.random() * 3))
  186. yield* Effect.yieldNow
  187. yield* Effect.promise(() => Promise.resolve())
  188. }
  189. return yield* InstanceState.get(state)
  190. }),
  191. })
  192. }),
  193. )
  194. }
  195. yield* Effect.gen(function* () {
  196. const results = yield* Effect.all(
  197. dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstanceEffect(dir))),
  198. { concurrency: "unbounded" },
  199. )
  200. expect(results).toEqual(dirs)
  201. }).pipe(Effect.provide(Test.layer))
  202. }),
  203. )
  204. it.live("InstanceState correct after interleaved init and dispose", () =>
  205. Effect.gen(function* () {
  206. const one = yield* tmpdirScoped()
  207. const two = yield* tmpdirScoped()
  208. interface Api {
  209. readonly get: () => Effect.Effect<string>
  210. }
  211. class Test extends Context.Service<Test, Api>()("@test/InterleavedDispose") {
  212. static readonly layer = Layer.effect(
  213. Test,
  214. Effect.gen(function* () {
  215. const state = yield* InstanceState.make((ctx) =>
  216. Effect.gen(function* () {
  217. yield* Effect.sleep(Duration.millis(5))
  218. return ctx.directory
  219. }),
  220. )
  221. return Test.of({
  222. get: Effect.fn("Test.get")(function* () {
  223. return yield* InstanceState.get(state)
  224. }),
  225. })
  226. }),
  227. )
  228. }
  229. yield* Effect.gen(function* () {
  230. const a = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
  231. expect(a).toBe(one)
  232. const [, b] = yield* Effect.all(
  233. [reloadInstance({ directory: one }), Test.use((svc) => svc.get()).pipe(provideInstanceEffect(two))],
  234. { concurrency: "unbounded" },
  235. )
  236. expect(b).toBe(two)
  237. const c = yield* Test.use((svc) => svc.get()).pipe(provideInstanceEffect(one))
  238. expect(c).toBe(one)
  239. }).pipe(Effect.provide(Test.layer))
  240. }),
  241. )
  242. it.live("InstanceState mutation in one directory does not leak to another", () =>
  243. Effect.gen(function* () {
  244. const one = yield* tmpdirScoped()
  245. const two = yield* tmpdirScoped()
  246. const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
  247. const s1 = yield* access(state, one)
  248. s1.count = 42
  249. const s2 = yield* access(state, two)
  250. expect(s2.count).toBe(0)
  251. const s1again = yield* access(state, one)
  252. expect(s1again.count).toBe(42)
  253. expect(s1again).toBe(s1)
  254. }),
  255. )
  256. it.live("InstanceState dedupes concurrent lookups", () =>
  257. Effect.gen(function* () {
  258. const dir = yield* tmpdirScoped()
  259. let n = 0
  260. const state = yield* InstanceState.make(() =>
  261. Effect.gen(function* () {
  262. n += 1
  263. yield* Effect.sleep(Duration.millis(10))
  264. return { n }
  265. }),
  266. )
  267. const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" })
  268. expect(a).toBe(b)
  269. expect(n).toBe(1)
  270. }),
  271. )
  272. it.live("InstanceState survives deferred resume from the same instance context", () =>
  273. Effect.gen(function* () {
  274. const dir = yield* tmpdirScoped({ git: true })
  275. interface Api {
  276. readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
  277. }
  278. class Test extends Context.Service<Test, Api>()("@test/DeferredResume") {
  279. static readonly layer = Layer.effect(
  280. Test,
  281. Effect.gen(function* () {
  282. const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
  283. return Test.of({
  284. get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
  285. yield* Deferred.await(gate)
  286. return yield* InstanceState.get(state)
  287. }),
  288. })
  289. }),
  290. )
  291. }
  292. yield* Effect.gen(function* () {
  293. const gate = yield* Deferred.make<void>()
  294. const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped)
  295. yield* Deferred.succeed(gate, undefined).pipe(provideInstanceEffect(dir))
  296. const exit = yield* Fiber.await(fiber)
  297. expect(Exit.isSuccess(exit)).toBe(true)
  298. if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
  299. }).pipe(Effect.provide(Test.layer))
  300. }),
  301. )
  302. it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () =>
  303. Effect.gen(function* () {
  304. const dir = yield* tmpdirScoped({ git: true })
  305. interface Api {
  306. readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
  307. }
  308. class Test extends Context.Service<Test, Api>()("@test/DeferredResumeOutside") {
  309. static readonly layer = Layer.effect(
  310. Test,
  311. Effect.gen(function* () {
  312. const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
  313. return Test.of({
  314. get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
  315. yield* Deferred.await(gate)
  316. return yield* InstanceState.get(state)
  317. }),
  318. })
  319. }),
  320. )
  321. }
  322. yield* Effect.gen(function* () {
  323. const gate = yield* Deferred.make<void>()
  324. const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstanceEffect(dir), Effect.forkScoped)
  325. yield* Deferred.succeed(gate, undefined)
  326. const exit = yield* Fiber.await(fiber)
  327. expect(Exit.isSuccess(exit)).toBe(true)
  328. if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
  329. }).pipe(Effect.provide(Test.layer))
  330. }),
  331. )