instance-state.test.ts 11 KB

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