test(opencode): pin lifecycle invariants for DatabaseEffect + managed-runtime
Adds regression tests for the two non-obvious invariants enforced by the Effect-Drizzle integration: - packages/opencode/test/storage/db-effect.test.ts pins that DatabaseEffect.layer rebuilds a fresh handle after Database.close + dispose, and demonstrates the shared-memoMap poisoning that resetDatabase prevents by disposing every DB-consuming runtime before closing the SQLite handle. - packages/opencode/test/effect/managed-runtime.test.ts pins makeManagedRuntime dispose semantics and the lazy.resetIf compare-and-reset guard so a rebuilt instance is never clobbered by a stale dispose.
This commit is contained in:
parent
46996e5a67
commit
f948a1e3b0
2 changed files with 197 additions and 0 deletions
78
packages/opencode/test/effect/managed-runtime.test.ts
Normal file
78
packages/opencode/test/effect/managed-runtime.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { makeManagedRuntime } from "@/effect/managed-runtime"
|
||||||
|
import { lazy } from "@/util/lazy"
|
||||||
|
|
||||||
|
class Counter extends Context.Service<Counter, { readonly value: number }>()("@test/Counter") {}
|
||||||
|
|
||||||
|
const layerWith = (value: number) => Layer.succeed(Counter, { value })
|
||||||
|
|
||||||
|
describe("makeManagedRuntime", () => {
|
||||||
|
test("disposing an unbuilt runtime is a no-op", async () => {
|
||||||
|
const rt = makeManagedRuntime(layerWith(0))
|
||||||
|
expect(rt.peek()).toBeUndefined()
|
||||||
|
await rt.dispose()
|
||||||
|
expect(rt.peek()).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disposing rebuilds on next access", async () => {
|
||||||
|
const rt = makeManagedRuntime(layerWith(7))
|
||||||
|
const first = rt()
|
||||||
|
const value = await first.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
return (yield* Counter).value
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(value).toBe(7)
|
||||||
|
|
||||||
|
await rt.dispose()
|
||||||
|
expect(rt.peek()).toBeUndefined()
|
||||||
|
|
||||||
|
const second = rt()
|
||||||
|
expect(second).not.toBe(first)
|
||||||
|
expect(rt.peek()).toBe(second)
|
||||||
|
await rt.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("dispose() does not clobber a runtime that was rebuilt mid-dispose", async () => {
|
||||||
|
// Simulates a race where dispose() ran on instance A, then someone
|
||||||
|
// invoked the lazy and got a fresh instance B before dispose() returned.
|
||||||
|
// The resetIf guard must leave instance B intact.
|
||||||
|
const rt = makeManagedRuntime(layerWith(1))
|
||||||
|
const first = rt()
|
||||||
|
rt.reset() // force-eject the lazy
|
||||||
|
const second = rt() // build a new instance, distinct from first
|
||||||
|
expect(second).not.toBe(first)
|
||||||
|
|
||||||
|
// Calling dispose() now should tear down `second` (the current value),
|
||||||
|
// not the orphaned `first`.
|
||||||
|
await rt.dispose()
|
||||||
|
expect(rt.peek()).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("lazy.resetIf", () => {
|
||||||
|
test("resets when the value matches", () => {
|
||||||
|
const factory = lazy(() => ({}))
|
||||||
|
const value = factory()
|
||||||
|
expect(factory.peek()).toBe(value)
|
||||||
|
factory.resetIf(value)
|
||||||
|
expect(factory.peek()).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("leaves the lazy intact when the value does not match", () => {
|
||||||
|
const factory = lazy(() => ({}))
|
||||||
|
const captured = factory()
|
||||||
|
factory.reset()
|
||||||
|
const fresh = factory()
|
||||||
|
expect(fresh).not.toBe(captured)
|
||||||
|
factory.resetIf(captured)
|
||||||
|
expect(factory.peek()).toBe(fresh)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("is a no-op on an unloaded lazy", () => {
|
||||||
|
const factory = lazy(() => ({}))
|
||||||
|
factory.resetIf({} as never)
|
||||||
|
expect(factory.peek()).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
119
packages/opencode/test/storage/db-effect.test.ts
Normal file
119
packages/opencode/test/storage/db-effect.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { Effect, ManagedRuntime } from "effect"
|
||||||
|
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||||
|
import { Database } from "@/storage/db"
|
||||||
|
import { DatabaseEffect } from "@/storage/db-effect"
|
||||||
|
import { resetDatabase } from "../fixture/db"
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await resetDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("DatabaseEffect.layer", () => {
|
||||||
|
test("yields a working Service that round-trips a query", async () => {
|
||||||
|
const rt = ManagedRuntime.make(DatabaseEffect.layer)
|
||||||
|
try {
|
||||||
|
const value = await rt.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* DatabaseEffect.Service
|
||||||
|
return db.$client.prepare("SELECT 42 as n").get() as { n: number }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(value).toEqual({ n: 42 })
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rebuilds a fresh handle after Database.close + runtime dispose", async () => {
|
||||||
|
const rt1 = ManagedRuntime.make(DatabaseEffect.layer)
|
||||||
|
const first = await rt1.runPromise(Effect.sync(() => Database.Client().$client))
|
||||||
|
expect(first.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||||
|
|
||||||
|
await rt1.dispose()
|
||||||
|
Database.close()
|
||||||
|
|
||||||
|
const rt2 = ManagedRuntime.make(DatabaseEffect.layer)
|
||||||
|
try {
|
||||||
|
const second = await rt2.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* DatabaseEffect.Service
|
||||||
|
return db.$client
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(second).not.toBe(first)
|
||||||
|
expect(second.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||||
|
} finally {
|
||||||
|
await rt2.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression for the memoMap lifecycle bug. The shared layer memoMap caches
|
||||||
|
// every `DatabaseEffect.layer` build across every runtime built with
|
||||||
|
// `makeManagedRuntime`. If a runtime that consumed the layer is NOT disposed
|
||||||
|
// before `Database.close()`, the cached Service value (a Drizzle wrapper
|
||||||
|
// over a now-closed `bun:sqlite` handle) persists in the memoMap and any
|
||||||
|
// subsequent runtime that consumes the layer reuses it and operates on a
|
||||||
|
// closed handle.
|
||||||
|
//
|
||||||
|
// `test/fixture/db.ts:resetDatabase` disposes every module-scoped runtime
|
||||||
|
// before closing the DB to release the memoMap entries. The two tests below
|
||||||
|
// pin both halves of the invariant.
|
||||||
|
describe("DatabaseEffect.layer + shared memoMap lifecycle", () => {
|
||||||
|
test("disposing a runtime releases its memoMap entry so the next build sees a fresh DB handle", async () => {
|
||||||
|
const rt1 = ManagedRuntime.make(DatabaseEffect.layer, { memoMap })
|
||||||
|
const captured = await rt1.runPromise(Effect.sync(() => Database.Client().$client))
|
||||||
|
expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||||
|
|
||||||
|
await rt1.dispose()
|
||||||
|
Database.close()
|
||||||
|
|
||||||
|
const rt2 = ManagedRuntime.make(DatabaseEffect.layer, { memoMap })
|
||||||
|
try {
|
||||||
|
const fresh = await rt2.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* DatabaseEffect.Service
|
||||||
|
return db.$client
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(fresh).not.toBe(captured)
|
||||||
|
expect(fresh.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||||
|
} finally {
|
||||||
|
await rt2.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a stale runtime kept alive over Database.close poisons later memoMap consumers", async () => {
|
||||||
|
const stale = ManagedRuntime.make(DatabaseEffect.layer, { memoMap })
|
||||||
|
const captured = await stale.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* DatabaseEffect.Service
|
||||||
|
return db.$client
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(captured.prepare("SELECT 1 as n").get()).toEqual({ n: 1 })
|
||||||
|
|
||||||
|
// Intentionally do NOT dispose `stale` before closing the DB. This is
|
||||||
|
// the shape of the bug `resetDatabase` guards against.
|
||||||
|
Database.close()
|
||||||
|
|
||||||
|
const next = ManagedRuntime.make(DatabaseEffect.layer, { memoMap })
|
||||||
|
try {
|
||||||
|
const seen = await next.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = yield* DatabaseEffect.Service
|
||||||
|
return db.$client
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// The memoMap returned the same stale handle because `stale` was
|
||||||
|
// never disposed. The underlying connection is closed, so any query
|
||||||
|
// on the handle throws.
|
||||||
|
expect(seen).toBe(captured)
|
||||||
|
expect(() => seen.prepare("SELECT 1 as n").get()).toThrow()
|
||||||
|
} finally {
|
||||||
|
await next.dispose()
|
||||||
|
await stale.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue