instance-state.test.ts 14 KB

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