add runSync safety comment

This commit is contained in:
Kit Langton
2026-03-22 12:35:58 -04:00
parent 34b7173e92
commit e241b89eae
6 changed files with 77 additions and 30 deletions
+2
View File
@@ -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<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
@@ -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() })),
+22 -10
View File
@@ -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<E>(directory: string, body: Effect.Effect<void, E>) {
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.Service, never, never> = 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) {
+24 -5
View File
@@ -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<Config.
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
function git(...args: string[]) {
return spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(
Effect.flatMap((handle) => 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<Config.
return dir
})
}
export const provideInstance =
(directory: string) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.withFiber((fiber) =>
Effect.promise<A>(async () =>
Instance.provide({
directory,
fn: () => Effect.runPromiseWith(fiber.services as any)(self),
}),
),
)
export function tmpdirInstanceScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
return Effect.map(tmpdirScoped(options), (path) => ({
path,
provide: provideInstance(path),
}))
}
+3 -3
View File
@@ -43,12 +43,12 @@ export function withServices<S>(
export const provideInstance =
(directory: string) =>
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.withFiber((fiber) =>
<A, E = never, R = never>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.services<A, E, R>((fiber) =>
Effect.promise<A>(async () =>
Instance.provide({
directory,
fn: () => Effect.runPromiseWith(fiber.services as any)(self),
fn: () => Effect.runPromiseWith(fiber.services)(self),
}),
),
)
+25 -10
View File
@@ -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<S, E>(
directory: string,
layer: Layer.Layer<S, E, never>,
body: (rt: ManagedRuntime.ManagedRuntime<S, E>) => Promise<void>,
) {
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)))
})