diff --git a/packages/opencode/test/effect/managed-runtime.test.ts b/packages/opencode/test/effect/managed-runtime.test.ts new file mode 100644 index 0000000000..35ca382cc1 --- /dev/null +++ b/packages/opencode/test/effect/managed-runtime.test.ts @@ -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()("@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() + }) +}) diff --git a/packages/opencode/test/storage/db-effect.test.ts b/packages/opencode/test/storage/db-effect.test.ts new file mode 100644 index 0000000000..648eaaae9a --- /dev/null +++ b/packages/opencode/test/storage/db-effect.test.ts @@ -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() + } + }) +})