test: use testEffect for instance state (#25115)

This commit is contained in:
Kit Langton
2026-04-30 12:53:13 -04:00
committed by GitHub
parent 92e80b4660
commit 79e23b7eb9
@@ -1,70 +1,63 @@
import { afterEach, expect, test } from "bun:test" import { afterEach, expect } from "bun:test"
import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { $ } from "bun"
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
async function access<A, E>(state: InstanceState.InstanceState<A, E>, dir: string) { const it = testEffect(CrossSpawnSpawner.defaultLayer)
return Instance.provide({
directory: dir, const access = <A, E>(state: InstanceState.InstanceState<A, E>, dir: string) =>
fn: () => Effect.runPromise(InstanceState.get(state)), InstanceState.get(state).pipe(provideInstance(dir))
const tmpdirGitScoped = Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet())
return dir
}) })
}
afterEach(async () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
}) })
test("InstanceState caches values per directory", async () => { it.live("InstanceState caches values per directory", () =>
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* Effect.promise(() => access(state, tmp.path)) const a = yield* access(state, dir)
const b = yield* Effect.promise(() => access(state, tmp.path)) const b = yield* access(state, dir)
expect(a).toBe(b) expect(a).toBe(b)
expect(n).toBe(1) expect(n).toBe(1)
}), }),
),
) )
})
test("InstanceState isolates directories", async () => { it.live("InstanceState isolates directories", () =>
await using one = await tmpdir()
await using two = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
const a = yield* Effect.promise(() => access(state, one.path)) const a = yield* access(state, one)
const b = yield* Effect.promise(() => access(state, two.path)) const b = yield* access(state, two)
const c = yield* Effect.promise(() => access(state, one.path)) const c = yield* access(state, one)
expect(a).toBe(c) expect(a).toBe(c)
expect(a).not.toBe(b) expect(a).not.toBe(b)
expect(n).toBe(2) expect(n).toBe(2)
}), }),
),
) )
})
test("InstanceState invalidates on reload", async () => { it.live("InstanceState invalidates on reload", () =>
await using tmp = await tmpdir() Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const seen: string[] = [] const seen: string[] = []
let n = 0 let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => const state = yield* InstanceState.make(() =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })), Effect.sync(() => ({ n: ++n })),
@@ -75,25 +68,20 @@ test("InstanceState invalidates on reload", async () => {
), ),
) )
const a = yield* Effect.promise(() => access(state, tmp.path)) const a = yield* access(state, dir)
yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) yield* Effect.promise(() => Instance.reload({ directory: dir }))
const b = yield* Effect.promise(() => access(state, tmp.path)) const b = yield* access(state, dir)
expect(a).not.toBe(b) expect(a).not.toBe(b)
expect(seen).toEqual(["1"]) expect(seen).toEqual(["1"])
}), }),
),
) )
})
test("InstanceState invalidates on disposeAll", async () => { it.live("InstanceState invalidates on disposeAll", () =>
await using one = await tmpdir()
await using two = await tmpdir()
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const seen: string[] = []
const state = yield* InstanceState.make((ctx) => const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })), Effect.sync(() => ({ dir: ctx.directory })),
@@ -104,19 +92,18 @@ test("InstanceState invalidates on disposeAll", async () => {
), ),
) )
yield* Effect.promise(() => access(state, one.path)) yield* access(state, one)
yield* Effect.promise(() => access(state, two.path)) yield* access(state, two)
yield* Effect.promise(() => Instance.disposeAll()) yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one.path, two.path].sort()) expect(seen.sort()).toEqual([one, two].sort())
}), }),
),
) )
})
test("InstanceState.get reads the current directory lazily", async () => { it.live("InstanceState.get reads the current directory lazily", () =>
await using one = await tmpdir() Effect.gen(function* () {
await using two = await tmpdir() const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api { interface Api {
readonly get: () => Effect.Effect<string> readonly get: () => Effect.Effect<string>
@@ -138,29 +125,21 @@ test("InstanceState.get reads the current directory lazily", async () => {
) )
} }
const rt = ManagedRuntime.make(Test.layer) yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
const b = yield* Test.use((svc) => svc.get()).pipe(provideInstance(two))
try { expect(a).toBe(one)
const a = await Instance.provide({ expect(b).toBe(two)
directory: one.path, }).pipe(Effect.provide(Test.layer))
fn: () => rt.runPromise(Test.use((svc) => svc.get())), }),
}) )
const b = await Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path) it.live("InstanceState preserves directory across async boundaries", () =>
expect(b).toBe(two.path) Effect.gen(function* () {
} finally { const one = yield* tmpdirGitScoped
await rt.dispose() const two = yield* tmpdirGitScoped
} const three = yield* tmpdirGitScoped
})
test("InstanceState preserves directory across async boundaries", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await using three = await tmpdir({ git: true })
interface Api { interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
@@ -197,38 +176,28 @@ test("InstanceState preserves directory across async boundaries", async () => {
) )
} }
const rt = ManagedRuntime.make(Test.layer) yield* Effect.gen(function* () {
const [a, b, c] = yield* Effect.all(
[one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
{ concurrency: "unbounded" },
)
try { expect(a).toEqual({ directory: one, worktree: one, project: a.project })
const [a, b, c] = await Promise.all([ expect(b).toEqual({ directory: two, worktree: two, project: b.project })
Instance.provide({ expect(c).toEqual({ directory: three, worktree: three, project: c.project })
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: three.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
expect(a.project).not.toBe(b.project) expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project) expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project) expect(b.project).not.toBe(c.project)
} finally { }).pipe(Effect.provide(Test.layer))
await rt.dispose() }),
} )
})
test("InstanceState survives high-contention concurrent access", async () => { it.live("InstanceState survives high-contention concurrent access", () =>
const N = 20 Effect.gen(function* () {
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir())) const dirs = yield* Effect.all(
Array.from({ length: 20 }, () => tmpdirScoped()),
{ concurrency: "unbounded" },
)
interface Api { interface Api {
readonly get: () => Effect.Effect<string> readonly get: () => Effect.Effect<string>
@@ -242,7 +211,6 @@ test("InstanceState survives high-contention concurrent access", async () => {
return Test.of({ return Test.of({
get: Effect.fn("Test.get")(function* () { get: Effect.fn("Test.get")(function* () {
// Interleave many async hops to maximize chance of ALS corruption
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3)) yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow yield* Effect.yieldNow
@@ -255,30 +223,21 @@ test("InstanceState survives high-contention concurrent access", async () => {
) )
} }
const rt = ManagedRuntime.make(Test.layer) yield* Effect.gen(function* () {
const results = yield* Effect.all(
try { dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))),
const results = await Promise.all( { concurrency: "unbounded" },
dirs.map((d) =>
Instance.provide({
directory: d.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
),
) )
for (let i = 0; i < N; i++) { expect(results).toEqual(dirs)
expect(results[i]).toBe(dirs[i].path) }).pipe(Effect.provide(Test.layer))
} }),
} finally { )
await rt.dispose()
for (const d of dirs) await d[Symbol.asyncDispose]()
}
})
test("InstanceState correct after interleaved init and dispose", async () => { it.live("InstanceState correct after interleaved init and dispose", () =>
await using one = await tmpdir() Effect.gen(function* () {
await using two = await tmpdir() const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
interface Api { interface Api {
readonly get: () => Effect.Effect<string> readonly get: () => Effect.Effect<string>
@@ -290,7 +249,7 @@ test("InstanceState correct after interleaved init and dispose", async () => {
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => { Effect.promise(async () => {
await Bun.sleep(5) // slow init await Bun.sleep(5)
return ctx.directory return ctx.directory
}), }),
) )
@@ -304,70 +263,47 @@ test("InstanceState correct after interleaved init and dispose", async () => {
) )
} }
const rt = ManagedRuntime.make(Test.layer) yield* Effect.gen(function* () {
const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
expect(a).toBe(one)
try { const [, b] = yield* Effect.all(
// Init both directories [
const a = await Instance.provide({ Effect.promise(() => Instance.reload({ directory: one })),
directory: one.path, Test.use((svc) => svc.get()).pipe(provideInstance(two)),
fn: () => rt.runPromise(Test.use((svc) => svc.get())), ],
}) { concurrency: "unbounded" },
expect(a).toBe(one.path) )
expect(b).toBe(two)
// Dispose one directory, access the other concurrently const c = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one))
const [, b] = await Promise.all([ expect(c).toBe(one)
Instance.reload({ directory: one.path }), }).pipe(Effect.provide(Test.layer))
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}), }),
]) )
expect(b).toBe(two.path)
// Re-access disposed directory - should get fresh state it.live("InstanceState mutation in one directory does not leak to another", () =>
const c = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(c).toBe(one.path)
} finally {
await rt.dispose()
}
})
test("InstanceState mutation in one directory does not leak to another", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
// Mutate state in directory one const s1 = yield* access(state, one)
const s1 = yield* Effect.promise(() => access(state, one.path))
s1.count = 42 s1.count = 42
// Access directory two — should be independent const s2 = yield* access(state, two)
const s2 = yield* Effect.promise(() => access(state, two.path))
expect(s2.count).toBe(0) expect(s2.count).toBe(0)
// Confirm directory one still has the mutation const s1again = yield* access(state, one)
const s1again = yield* Effect.promise(() => access(state, one.path))
expect(s1again.count).toBe(42) expect(s1again.count).toBe(42)
expect(s1again).toBe(s1) // same reference expect(s1again).toBe(s1)
}), }),
),
) )
})
test("InstanceState dedupes concurrent lookups", async () => { it.live("InstanceState dedupes concurrent lookups", () =>
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const dir = yield* tmpdirScoped()
let n = 0
const state = yield* InstanceState.make(() => const state = yield* InstanceState.make(() =>
Effect.promise(async () => { Effect.promise(async () => {
n += 1 n += 1
@@ -376,16 +312,15 @@ test("InstanceState dedupes concurrent lookups", async () => {
}), }),
) )
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" })
expect(a).toBe(b) expect(a).toBe(b)
expect(n).toBe(1) expect(n).toBe(1)
}), }),
),
) )
})
test("InstanceState survives deferred resume from the same instance context", async () => { it.live("InstanceState survives deferred resume from the same instance context", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
interface Api { interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string> readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
@@ -407,32 +342,22 @@ test("InstanceState survives deferred resume from the same instance context", as
) )
} }
const rt = ManagedRuntime.make(Test.layer) yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
try { yield* Deferred.succeed(gate, undefined).pipe(provideInstance(dir))
const gate = await Effect.runPromise(Deferred.make<void>()) const exit = yield* Fiber.await(fiber)
const fiber = await Instance.provide({
directory: tmp.path,
fn: () => Promise.resolve(rt.runFork(Test.use((svc) => svc.get(gate)))),
})
await Instance.provide({
directory: tmp.path,
fn: () => Effect.runPromise(Deferred.succeed(gate, void 0)),
})
const exit = await Effect.runPromise(Fiber.await(fiber))
expect(Exit.isSuccess(exit)).toBe(true) expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) { if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
expect(exit.value).toBe(tmp.path) }).pipe(Effect.provide(Test.layer))
} }),
} finally { )
await rt.dispose()
}
})
test("InstanceState survives deferred resume outside ALS when InstanceRef is set", async () => { it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
interface Api { interface Api {
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string> readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
@@ -454,29 +379,15 @@ test("InstanceState survives deferred resume outside ALS when InstanceRef is set
) )
} }
const rt = ManagedRuntime.make(Test.layer) yield* Effect.gen(function* () {
const gate = yield* Deferred.make<void>()
const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped)
try { yield* Deferred.succeed(gate, undefined)
const gate = await Effect.runPromise(Deferred.make<void>()) const exit = yield* Fiber.await(fiber)
// Provide InstanceRef so the fiber carries the context even when
// the deferred is resolved from outside Instance.provide ALS.
const fiber = await Instance.provide({
directory: tmp.path,
fn: () =>
Promise.resolve(
rt.runFork(Test.use((svc) => svc.get(gate)).pipe(Effect.provideService(InstanceRef, Instance.current))),
),
})
// Resume from outside any Instance.provide — ALS is NOT set here
await Effect.runPromise(Deferred.succeed(gate, void 0))
const exit = await Effect.runPromise(Fiber.await(fiber))
expect(Exit.isSuccess(exit)).toBe(true) expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) { if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir)
expect(exit.value).toBe(tmp.path) }).pipe(Effect.provide(Test.layer))
} }),
} finally { )
await rt.dispose()
}
})