diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index ae40d9cd96..464af05c72 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -166,6 +166,8 @@ export namespace Bus { const { runPromise, runSync } = makeRuntime(Service, layer) + // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, + // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. export async function publish(def: D, properties: z.output) { return runPromise((svc) => svc.publish(def, properties)) } diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 6652f24b33..4c3b9d085d 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -4,8 +4,7 @@ import z from "zod" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" -import { provideInstance } from "../fixture/instance" +import { provideInstance, tmpdir } from "../fixture/fixture" const TestEvent = { Ping: BusEvent.define("test.effect.ping", z.object({ value: z.number() })), diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 8cbd478cba..9835757b17 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -2,9 +2,8 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" -import { Deferred, Effect, Option } from "effect" +import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" import { tmpdir } from "../fixture/fixture" -import { watcherConfigLayer, withServices } from "../fixture/instance" import { Bus } from "../../src/bus" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" @@ -16,20 +15,33 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc // Helpers // --------------------------------------------------------------------------- +const watcherConfigLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", + }), +) + type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ function withWatcher(directory: string, body: Effect.Effect) { - return withServices( + return Instance.provide({ directory, - FileWatcher.layer, - async (rt) => { - await rt.runPromise(FileWatcher.Service.use((s) => s.init())) - await Effect.runPromise(ready(directory)) - await Effect.runPromise(body) + fn: async () => { + const layer: Layer.Layer = FileWatcher.layer.pipe( + Layer.provide(watcherConfigLayer), + ) + const rt = ManagedRuntime.make(layer) + try { + await rt.runPromise(FileWatcher.Service.use((s) => s.init())) + await Effect.runPromise(ready(directory)) + await Effect.runPromise(body) + } finally { + await rt.dispose() + } }, - { provide: [watcherConfigLayer] }, - ) + }) } function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 0fc1576180..776c6d52f6 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -5,6 +5,7 @@ import path from "path" import { Effect, FileSystem } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { @@ -81,11 +82,10 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial handle.exitCode), - ) - } + const git = (...args: string[]) => + spawner + .spawn(ChildProcess.make("git", args, { cwd: dir })) + .pipe(Effect.flatMap((handle) => handle.exitCode)) if (options?.git) { yield* git("init") @@ -105,3 +105,22 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial + (self: Effect.Effect): Effect.Effect => + Effect.withFiber((fiber) => + Effect.promise(async () => + Instance.provide({ + directory, + fn: () => Effect.runPromiseWith(fiber.services as any)(self), + }), + ), + ) + +export function tmpdirInstanceScoped(options?: { git?: boolean; config?: Partial }) { + return Effect.map(tmpdirScoped(options), (path) => ({ + path, + provide: provideInstance(path), + })) +} diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index ebe1a3ef12..6f30846a43 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -43,12 +43,12 @@ export function withServices( export const provideInstance = (directory: string) => - (self: Effect.Effect): Effect.Effect => - Effect.withFiber((fiber) => + (self: Effect.Effect): Effect.Effect => + Effect.services((fiber) => Effect.promise(async () => Instance.provide({ directory, - fn: () => Effect.runPromiseWith(fiber.services as any)(self), + fn: () => Effect.runPromiseWith(fiber.services)(self), }), ), ) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 06e3ef9b35..d081090d6c 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -1,13 +1,28 @@ -import { Effect } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" import { afterEach, describe, expect, test } from "bun:test" import { tmpdir } from "../fixture/fixture" -import { withServices } from "../fixture/instance" -import { Bus } from "../../src/bus" -import { File } from "../../src/file" import { Format } from "../../src/format" import * as Formatter from "../../src/format/formatter" import { Instance } from "../../src/project/instance" +function withRuntime( + directory: string, + layer: Layer.Layer, + body: (rt: ManagedRuntime.ManagedRuntime) => Promise, +) { + return Instance.provide({ + directory, + fn: async () => { + const rt = ManagedRuntime.make(layer) + try { + await body(rt) + } finally { + await rt.dispose() + } + }, + }) +} + describe("Format", () => { afterEach(async () => { await Instance.disposeAll() @@ -16,7 +31,7 @@ describe("Format", () => { test("status() returns built-in formatters when no config overrides", async () => { await using tmp = await tmpdir() - await withServices(tmp.path, Format.layer, async (rt) => { + await withRuntime(tmp.path, Format.layer, async (rt) => { const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) expect(Array.isArray(statuses)).toBe(true) expect(statuses.length).toBeGreaterThan(0) @@ -38,7 +53,7 @@ describe("Format", () => { config: { formatter: false }, }) - await withServices(tmp.path, Format.layer, async (rt) => { + await withRuntime(tmp.path, Format.layer, async (rt) => { const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) expect(statuses).toEqual([]) }) @@ -53,7 +68,7 @@ describe("Format", () => { }, }) - await withServices(tmp.path, Format.layer, async (rt) => { + await withRuntime(tmp.path, Format.layer, async (rt) => { const statuses = await rt.runPromise(Format.Service.use((s) => s.status())) const gofmt = statuses.find((s) => s.name === "gofmt") expect(gofmt).toBeUndefined() @@ -63,7 +78,7 @@ describe("Format", () => { test("service initializes without error", async () => { await using tmp = await tmpdir() - await withServices(tmp.path, Format.layer, async (rt) => { + await withRuntime(tmp.path, Format.layer, async (rt) => { await rt.runPromise(Format.Service.use(() => Effect.void)) }) }) @@ -127,7 +142,7 @@ describe("Format", () => { } try { - await withServices(tmp.path, Format.layer, async (rt) => { + await withRuntime(tmp.path, Format.layer, async (rt) => { await rt.runPromise(Format.Service.use((s) => s.init())) await rt.runPromise(Format.Service.use((s) => s.file(file))) }) @@ -162,7 +177,7 @@ describe("Format", () => { const file = `${tmp.path}/test.seq` await Bun.write(file, "x") - await withServices(tmp.path, Format.layer, async (rt) => { + await withRuntime(tmp.path, Format.layer, async (rt) => { await rt.runPromise(Format.Service.use((s) => s.init())) await rt.runPromise(Format.Service.use((s) => s.file(file))) })