instance-state.test.ts 12 KB

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