From 469c3a4204310aa3b87f2355122d392baad312df Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 12:55:14 -0400 Subject: [PATCH] refactor(instance): move scoped services to LayerMap (#17544) --- .../opencode/src/effect/instance-registry.ts | 12 + packages/opencode/src/effect/instances.ts | 52 ++++ packages/opencode/src/effect/runtime.ts | 13 +- packages/opencode/src/permission/next.ts | 35 +-- packages/opencode/src/permission/service.ts | 56 ++-- packages/opencode/src/project/instance.ts | 14 +- .../opencode/src/provider/auth-service.ts | 57 ++-- packages/opencode/src/provider/auth.ts | 26 +- packages/opencode/src/question/index.ts | 15 +- packages/opencode/src/question/service.ts | 11 +- packages/opencode/src/util/instance-state.ts | 63 ----- .../opencode/test/permission/next.test.ts | 10 +- packages/opencode/test/provider/auth.test.ts | 20 -- .../opencode/test/question/question.test.ts | 6 +- .../opencode/test/util/instance-state.test.ts | 261 ------------------ 15 files changed, 154 insertions(+), 497 deletions(-) create mode 100644 packages/opencode/src/effect/instance-registry.ts create mode 100644 packages/opencode/src/effect/instances.ts delete mode 100644 packages/opencode/src/util/instance-state.ts delete mode 100644 packages/opencode/test/provider/auth.test.ts delete mode 100644 packages/opencode/test/util/instance-state.test.ts diff --git a/packages/opencode/src/effect/instance-registry.ts b/packages/opencode/src/effect/instance-registry.ts new file mode 100644 index 000000000..59c556e04 --- /dev/null +++ b/packages/opencode/src/effect/instance-registry.ts @@ -0,0 +1,12 @@ +const disposers = new Set<(directory: string) => Promise>() + +export function registerDisposer(disposer: (directory: string) => Promise) { + disposers.add(disposer) + return () => { + disposers.delete(disposer) + } +} + +export async function disposeInstance(directory: string) { + await Promise.allSettled([...disposers].map((disposer) => disposer(directory))) +} diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts new file mode 100644 index 000000000..02d4bf482 --- /dev/null +++ b/packages/opencode/src/effect/instances.ts @@ -0,0 +1,52 @@ +import { Effect, Layer, LayerMap, ServiceMap } from "effect" +import { registerDisposer } from "./instance-registry" +import { ProviderAuthService } from "@/provider/auth-service" +import { QuestionService } from "@/question/service" +import { PermissionService } from "@/permission/service" +import { Instance } from "@/project/instance" +import type { Project } from "@/project/project" + +export declare namespace InstanceContext { + export interface Shape { + readonly directory: string + readonly project: Project.Info + } +} + +export class InstanceContext extends ServiceMap.Service()( + "opencode/InstanceContext", +) {} + +export type InstanceServices = QuestionService | PermissionService | ProviderAuthService + +function lookup(directory: string) { + const project = Instance.project + const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project })) + return Layer.mergeAll( + Layer.fresh(QuestionService.layer), + Layer.fresh(PermissionService.layer), + Layer.fresh(ProviderAuthService.layer), + ).pipe(Layer.provide(ctx)) +} + +export class Instances extends ServiceMap.Service>()( + "opencode/Instances", +) { + static readonly layer = Layer.effect( + Instances, + Effect.gen(function* () { + const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity }) + const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) + yield* Effect.addFinalizer(() => Effect.sync(unregister)) + return Instances.of(layerMap) + }), + ) + + static get(directory: string): Layer.Layer { + return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) + } + + static invalidate(directory: string): Effect.Effect { + return Instances.use((map) => map.invalidate(directory)) + } +} diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 4aec46bef..02a7391d4 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,9 +1,14 @@ -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" import { AccountService } from "@/account/service" import { AuthService } from "@/auth/service" -import { PermissionService } from "@/permission/service" -import { QuestionService } from "@/question/service" +import { Instances } from "@/effect/instances" +import type { InstanceServices } from "@/effect/instances" +import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( - Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer), + Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)), ) + +export function runPromiseInstance(effect: Effect.Effect) { + return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) +} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 7fcd40eea..6a65a6f2e 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -1,18 +1,9 @@ -import { runtime } from "@/effect/runtime" +import { runPromiseInstance } from "@/effect/runtime" import { Config } from "@/config/config" import { fn } from "@/util/fn" import { Wildcard } from "@/util/wildcard" -import { Effect } from "effect" import os from "os" import * as S from "./service" -import type { - Action as ActionType, - PermissionError, - Reply as ReplyType, - Request as RequestType, - Rule as RuleType, - Ruleset as RulesetType, -} from "./service" export namespace PermissionNext { function expand(pattern: string): string { @@ -23,20 +14,16 @@ export namespace PermissionNext { return pattern } - function runPromise(f: (service: S.PermissionService.Api) => Effect.Effect) { - return runtime.runPromise(S.PermissionService.use(f)) - } - export const Action = S.Action - export type Action = ActionType + export type Action = S.Action export const Rule = S.Rule - export type Rule = RuleType + export type Rule = S.Rule export const Ruleset = S.Ruleset - export type Ruleset = RulesetType + export type Ruleset = S.Ruleset export const Request = S.Request - export type Request = RequestType + export type Request = S.Request export const Reply = S.Reply - export type Reply = ReplyType + export type Reply = S.Reply export const Approval = S.Approval export const Event = S.Event export const Service = S.PermissionService @@ -66,12 +53,16 @@ export namespace PermissionNext { return rulesets.flat() } - export const ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input))) + export const ask = fn(S.AskInput, async (input) => + runPromiseInstance(S.PermissionService.use((service) => service.ask(input))), + ) - export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input))) + export const reply = fn(S.ReplyInput, async (input) => + runPromiseInstance(S.PermissionService.use((service) => service.reply(input))), + ) export async function list() { - return runPromise((service) => service.list()) + return runPromiseInstance(S.PermissionService.use((service) => service.list())) } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index 2782c0aba..b790158d1 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -1,11 +1,10 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { Instance } from "@/project/instance" +import { InstanceContext } from "@/effect/instances" import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" -import { InstanceState } from "@/util/instance-state" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" @@ -104,11 +103,6 @@ interface PendingEntry { deferred: Deferred.Deferred } -type State = { - pending: Map - approved: Ruleset -} - export const AskInput = Request.partial({ id: true }).extend({ ruleset: Ruleset, }) @@ -133,25 +127,19 @@ export class PermissionService extends ServiceMap.Service(() => - Effect.sync(() => { - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Instance.project.id)).get(), - ) - return { - pending: new Map(), - approved: row?.data ?? [], - } - }), + const { project } = yield* InstanceContext + const row = Database.use((db) => + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(), ) + const pending = new Map() + const approved: Ruleset = row?.data ?? [] const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer) { - const state = yield* InstanceState.get(instanceState) const { ruleset, ...request } = input - let pending = false + let needsAsk = false for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, state.approved) + const rule = evaluate(request.permission, pattern, ruleset, approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { return yield* new DeniedError({ @@ -159,10 +147,10 @@ export class PermissionService extends ServiceMap.Service() - state.pending.set(id, { info, deferred }) + pending.set(id, { info, deferred }) void Bus.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), Effect.sync(() => { - state.pending.delete(id) + pending.delete(id) }), ) }) const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer) { - const state = yield* InstanceState.get(instanceState) - const existing = state.pending.get(input.requestID) + const existing = pending.get(input.requestID) if (!existing) return - state.pending.delete(input.requestID) + pending.delete(input.requestID) void Bus.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, @@ -200,9 +187,9 @@ export class PermissionService extends ServiceMap.Service evaluate(item.info.permission, pattern, state.approved).action === "allow", + (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", ) if (!ok) continue - state.pending.delete(id) + pending.delete(id) void Bus.publish(Event.Replied, { sessionID: item.info.sessionID, requestID: item.info.id, @@ -246,8 +233,7 @@ export class PermissionService extends ServiceMap.Service item.info) + return Array.from(pending.values(), (item) => item.info) }) return PermissionService.of({ ask, reply, list }) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index dac5e71ba..fd3cc640a 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,4 +1,3 @@ -import { Effect } from "effect" import { Log } from "@/util/log" import { Context } from "../util/context" import { Project } from "./project" @@ -6,7 +5,7 @@ import { State } from "./state" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { Filesystem } from "@/util/filesystem" -import { InstanceState } from "@/util/instance-state" +import { disposeInstance } from "@/effect/instance-registry" interface Context { directory: string @@ -108,17 +107,18 @@ export const Instance = { async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { const directory = Filesystem.resolve(input.directory) Log.Default.info("reloading instance", { directory }) - await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))]) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) const next = track(directory, boot({ ...input, directory })) emit(directory) return await next }, async dispose() { - Log.Default.info("disposing instance", { directory: Instance.directory }) - await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))]) - cache.delete(Instance.directory) - emit(Instance.directory) + const directory = Instance.directory + Log.Default.info("disposing instance", { directory }) + await Promise.all([State.dispose(directory), disposeInstance(directory)]) + cache.delete(directory) + emit(directory) }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 2d9cec5cd..2e9985939 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -1,12 +1,9 @@ -import { Effect, Layer, Record, ServiceMap, Struct } from "effect" -import { Instance } from "@/project/instance" -import { Plugin } from "../plugin" -import { filter, fromEntries, map, pipe } from "remeda" import type { AuthOuathResult } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import * as Auth from "@/auth/service" -import { InstanceState } from "@/util/instance-state" import { ProviderID } from "./schema" +import { Effect, Layer, Record, ServiceMap, Struct } from "effect" +import { filter, fromEntries, map, pipe } from "remeda" import z from "zod" export const Method = z @@ -54,21 +51,13 @@ export type ProviderAuthError = export namespace ProviderAuthService { export interface Service { - /** Get available auth methods for each provider (e.g. OAuth, API key). */ readonly methods: () => Effect.Effect> - - /** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */ readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect - - /** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */ readonly callback: (input: { providerID: ProviderID method: number code?: string }) => Effect.Effect - - /** Set an API key directly for a provider (no OAuth flow). */ - readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect } } @@ -79,32 +68,29 @@ export class ProviderAuthService extends ServiceMap.Service - Effect.promise(async () => { - const methods = pipe( - await Plugin.list(), - filter((x) => x.auth?.provider !== undefined), - map((x) => [x.auth!.provider, x.auth!] as const), - fromEntries(), - ) - return { methods, pending: new Map() } - }), - ) + const hooks = yield* Effect.promise(async () => { + const mod = await import("../plugin") + return pipe( + await mod.Plugin.list(), + filter((x) => x.auth?.provider !== undefined), + map((x) => [x.auth!.provider, x.auth!] as const), + fromEntries(), + ) + }) + const pending = new Map() const methods = Effect.fn("ProviderAuthService.methods")(function* () { - const x = yield* InstanceState.get(state) - return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"]))) + return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"]))) }) const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { providerID: ProviderID method: number }) { - const s = yield* InstanceState.get(state) - const method = s.methods[input.providerID].methods[input.method] + const method = hooks[input.providerID].methods[input.method] if (method.type !== "oauth") return const result = yield* Effect.promise(() => method.authorize()) - s.pending.set(input.providerID, result) + pending.set(input.providerID, result) return { url: result.url, method: result.method, @@ -117,17 +103,14 @@ export class ProviderAuthService extends ServiceMap.Service match.method === "code" ? match.callback(input.code!) : match.callback(), ) - if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) if ("key" in result) { @@ -148,18 +131,10 @@ export class ProviderAuthService extends ServiceMap.Service(f: (service: S.ProviderAuthService.Service) => Effect.Effect) { - return rt.runPromise(S.ProviderAuthService.use(f)) -} - export namespace ProviderAuth { export const Method = S.Method export type Method = S.Method export async function methods() { - return runPromise((service) => service.methods()) + return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods())) } export const Authorization = S.Authorization @@ -30,7 +21,8 @@ export namespace ProviderAuth { providerID: ProviderID.zod, method: z.number(), }), - async (input): Promise => runPromise((service) => service.authorize(input)), + async (input): Promise => + runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))), ) export const callback = fn( @@ -39,15 +31,7 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => runPromise((service) => service.callback(input)), - ) - - export const api = fn( - z.object({ - providerID: ProviderID.zod, - key: z.string(), - }), - async (input) => runPromise((service) => service.api(input)), + async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))), ) export import OauthMissing = S.OauthMissing diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 6ace981a9..7fffc0c87 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,13 +1,8 @@ -import { Effect } from "effect" -import { runtime } from "@/effect/runtime" +import { runPromiseInstance } from "@/effect/runtime" import * as S from "./service" import type { QuestionID } from "./schema" import type { SessionID, MessageID } from "@/session/schema" -function runPromise(f: (service: S.QuestionService.Service) => Effect.Effect) { - return runtime.runPromise(S.QuestionService.use(f)) -} - export namespace Question { export const Option = S.Option export type Option = S.Option @@ -27,18 +22,18 @@ export namespace Question { questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - return runPromise((service) => service.ask(input)) + return runPromiseInstance(S.QuestionService.use((service) => service.ask(input))) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { - return runPromise((service) => service.reply(input)) + return runPromiseInstance(S.QuestionService.use((service) => service.reply(input))) } export async function reject(requestID: QuestionID): Promise { - return runPromise((service) => service.reject(requestID)) + return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID))) } export async function list(): Promise { - return runPromise((service) => service.list()) + return runPromiseInstance(S.QuestionService.use((service) => service.list())) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts index 30a47bee9..3df8286e6 100644 --- a/packages/opencode/src/question/service.ts +++ b/packages/opencode/src/question/service.ts @@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID } from "@/session/schema" -import { InstanceState } from "@/util/instance-state" import { Log } from "@/util/log" import z from "zod" import { QuestionID } from "./schema" @@ -104,18 +103,13 @@ export class QuestionService extends ServiceMap.Service>(() => - Effect.succeed(new Map()), - ) - - const getPending = InstanceState.get(instanceState) + const pending = new Map() const ask = Effect.fn("QuestionService.ask")(function* (input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }) { - const pending = yield* getPending const id = QuestionID.ascending() log.info("asking", { id, questions: input.questions.length }) @@ -138,7 +132,6 @@ export class QuestionService extends ServiceMap.Service x.info) }) diff --git a/packages/opencode/src/util/instance-state.ts b/packages/opencode/src/util/instance-state.ts deleted file mode 100644 index 4e5d36cf4..000000000 --- a/packages/opencode/src/util/instance-state.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Effect, ScopedCache, Scope } from "effect" - -import { Instance } from "@/project/instance" - -type Disposer = (directory: string) => Effect.Effect -const disposers = new Set() - -const TypeId = "~opencode/InstanceState" - -/** - * Effect version of `Instance.state` — lazily-initialized, per-directory - * cached state for Effect services. - * - * Values are created on first access for a given directory and cached for - * subsequent reads. Concurrent access shares a single initialization — - * no duplicate work or races. Use `Effect.acquireRelease` in `init` if - * the value needs cleanup on disposal. - */ -export interface InstanceState { - readonly [TypeId]: typeof TypeId - readonly cache: ScopedCache.ScopedCache -} - -export namespace InstanceState { - /** Create a new InstanceState with the given initializer. */ - export const make = ( - init: (directory: string) => Effect.Effect, - ): Effect.Effect>, never, R | Scope.Scope> => - Effect.gen(function* () { - const cache = yield* ScopedCache.make({ - capacity: Number.POSITIVE_INFINITY, - lookup: init, - }) - - const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory) - disposers.add(disposer) - yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer))) - - return { - [TypeId]: TypeId, - cache, - } - }) - - /** Get the cached value for the current directory, initializing it if needed. */ - export const get = (self: InstanceState) => - Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory)) - - /** Check whether a value exists for the current directory. */ - export const has = (self: InstanceState) => - Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory)) - - /** Invalidate the cached value for the current directory. */ - export const invalidate = (self: InstanceState) => - Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory)) - - /** Invalidate the given directory across all InstanceState caches. */ - export const dispose = (directory: string) => - Effect.all( - [...disposers].map((disposer) => disposer(directory)), - { concurrency: "unbounded" }, - ) -} diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index cd4775ace..2e9195c28 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,7 +1,9 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import os from "os" +import { Effect } from "effect" import { Bus } from "../../src/bus" import { runtime } from "../../src/effect/runtime" +import { Instances } from "../../src/effect/instances" import { PermissionNext } from "../../src/permission/next" import * as S from "../../src/permission/service" import { PermissionID } from "../../src/permission/schema" @@ -9,6 +11,10 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { MessageID, SessionID } from "../../src/session/schema" +afterEach(async () => { + await Instance.disposeAll() +}) + async function rejectAll(message?: string) { for (const req of await PermissionNext.list()) { await PermissionNext.reply({ @@ -1020,7 +1026,7 @@ test("ask - abort should clear pending request", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], }), - ), + ).pipe(Effect.provide(Instances.get(Instance.directory))), { signal: ctl.signal }, ) diff --git a/packages/opencode/test/provider/auth.test.ts b/packages/opencode/test/provider/auth.test.ts deleted file mode 100644 index 99babd44a..000000000 --- a/packages/opencode/test/provider/auth.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { afterEach, expect, test } from "bun:test" -import { Auth } from "../../src/auth" -import { ProviderAuth } from "../../src/provider/auth" -import { ProviderID } from "../../src/provider/schema" - -afterEach(async () => { - await Auth.remove("test-provider-auth") -}) - -test("ProviderAuth.api persists auth via AuthService", async () => { - await ProviderAuth.api({ - providerID: ProviderID.make("test-provider-auth"), - key: "sk-test", - }) - - expect(await Auth.get("test-provider-auth")).toEqual({ - type: "api", - key: "sk-test", - }) -}) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index ab5bc1d99..45e0d3c31 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,10 +1,14 @@ -import { test, expect } from "bun:test" +import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" import { QuestionID } from "../../src/question/schema" import { tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" +afterEach(async () => { + await Instance.disposeAll() +}) + /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ async function rejectAll() { const pending = await Question.list() diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts deleted file mode 100644 index 976b7d07e..000000000 --- a/packages/opencode/test/util/instance-state.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { afterEach, expect, test } from "bun:test" -import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect" - -import { Instance } from "../../src/project/instance" -import { InstanceState } from "../../src/util/instance-state" -import { tmpdir } from "../fixture/fixture" - -async function access(state: InstanceState, dir: string) { - return Instance.provide({ - directory: dir, - fn: () => Effect.runPromise(InstanceState.get(state)), - }) -} - -afterEach(async () => { - await Instance.disposeAll() -}) - -test("InstanceState caches values for the same instance", async () => { - await using tmp = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) - - const a = yield* Effect.promise(() => access(state, tmp.path)) - const b = yield* Effect.promise(() => access(state, tmp.path)) - - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -}) - -test("InstanceState isolates values by directory", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) - - const x = yield* Effect.promise(() => access(state, a.path)) - const y = yield* Effect.promise(() => access(state, b.path)) - const z = yield* Effect.promise(() => access(state, a.path)) - - expect(x).toBe(z) - expect(x).not.toBe(y) - expect(n).toBe(2) - }), - ), - ) -}) - -test("InstanceState is disposed on instance reload", async () => { - await using tmp = await tmpdir() - const seen: string[] = [] - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.acquireRelease( - Effect.sync(() => ({ n: ++n })), - (value) => - Effect.sync(() => { - seen.push(String(value.n)) - }), - ), - ) - - const a = yield* Effect.promise(() => access(state, tmp.path)) - yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) - const b = yield* Effect.promise(() => access(state, tmp.path)) - - expect(a).not.toBe(b) - expect(seen).toEqual(["1"]) - }), - ), - ) -}) - -test("InstanceState is disposed on disposeAll", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - const seen: string[] = [] - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => - Effect.acquireRelease( - Effect.sync(() => ({ dir })), - (value) => - Effect.sync(() => { - seen.push(value.dir) - }), - ), - ) - - yield* Effect.promise(() => access(state, a.path)) - yield* Effect.promise(() => access(state, b.path)) - yield* Effect.promise(() => Instance.disposeAll()) - - expect(seen.sort()).toEqual([a.path, b.path].sort()) - }), - ), - ) -}) - -test("InstanceState.get reads correct directory per-evaluation (not captured once)", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - - // Regression: InstanceState.get must be lazy (Effect.suspend) so the - // directory is read per-evaluation, not captured once at the call site. - // Without this, a service built inside a ManagedRuntime Layer would - // freeze to whichever directory triggered the first layer build. - - interface TestApi { - readonly getDir: () => Effect.Effect - } - - class TestService extends ServiceMap.Service()("@test/ALS-lazy") { - static readonly layer = Layer.effect( - TestService, - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => dir)) - // `get` is created once during layer build — must be lazy - const get = InstanceState.get(state) - - const getDir = Effect.fn("TestService.getDir")(function* () { - return yield* get - }) - - return TestService.of({ getDir }) - }), - ) - } - - const rt = ManagedRuntime.make(TestService.layer) - - try { - const resultA = await Instance.provide({ - directory: a.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }) - expect(resultA).toBe(a.path) - - // Second call with different directory must NOT return A's directory - const resultB = await Instance.provide({ - directory: b.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }) - expect(resultB).toBe(b.path) - } finally { - await rt.dispose() - } -}) - -test("InstanceState.get isolates concurrent fibers across real delays, yields, and timer callbacks", async () => { - await using a = await tmpdir() - await using b = await tmpdir() - await using c = await tmpdir() - - // Adversarial: concurrent fibers with real timer delays (macrotask - // boundaries via setTimeout/Bun.sleep), explicit scheduler yields, - // and many async steps. If ALS context leaks or gets lost at any - // point, a fiber will see the wrong directory. - - interface TestApi { - readonly getDir: () => Effect.Effect - } - - class TestService extends ServiceMap.Service()("@test/ALS-adversarial") { - static readonly layer = Layer.effect( - TestService, - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => dir)) - - const getDir = Effect.fn("TestService.getDir")(function* () { - // Mix of async boundary types to maximise interleaving: - // 1. Real timer delay (macrotask — setTimeout under the hood) - yield* Effect.promise(() => Bun.sleep(1)) - // 2. Effect.sleep (Effect's own timer, uses its internal scheduler) - yield* Effect.sleep(Duration.millis(1)) - // 3. Explicit scheduler yields - for (let i = 0; i < 100; i++) { - yield* Effect.yieldNow - } - // 4. Microtask boundaries - for (let i = 0; i < 100; i++) { - yield* Effect.promise(() => Promise.resolve()) - } - // 5. Another Effect.sleep - yield* Effect.sleep(Duration.millis(2)) - // 6. Another real timer to force a second macrotask hop - yield* Effect.promise(() => Bun.sleep(1)) - // NOW read the directory — ALS must still be correct - return yield* InstanceState.get(state) - }) - - return TestService.of({ getDir }) - }), - ) - } - - const rt = ManagedRuntime.make(TestService.layer) - - try { - const [resultA, resultB, resultC] = await Promise.all([ - Instance.provide({ - directory: a.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }), - Instance.provide({ - directory: b.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }), - Instance.provide({ - directory: c.path, - fn: () => rt.runPromise(TestService.use((s) => s.getDir())), - }), - ]) - - expect(resultA).toBe(a.path) - expect(resultB).toBe(b.path) - expect(resultC).toBe(c.path) - } finally { - await rt.dispose() - } -}) - -test("InstanceState dedupes concurrent lookups for the same directory", async () => { - await using tmp = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.promise(async () => { - n += 1 - await Bun.sleep(10) - return { n } - }), - ) - - const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -})