diff --git a/packages/cli/src/framework/startup-error.ts b/packages/cli/src/framework/startup-error.ts new file mode 100644 index 000000000000..0b6f21c54208 --- /dev/null +++ b/packages/cli/src/framework/startup-error.ts @@ -0,0 +1,30 @@ +import { Service } from "@opencode-ai/client/effect" +import { Cause, Effect } from "effect" + +const RED_BOLD = "\x1b[91m\x1b[1m" +const BOLD = "\x1b[1m" +const RESET = "\x1b[0m" + +export function handle(cause: Cause.Cause, command: string) { + const error = Cause.squash(cause) + if (!(error instanceof Service.StartError)) return Effect.failCause(cause) + return Effect.gen(function* () { + yield* Effect.logError("background service startup failed", { cause: Cause.pretty(cause) }) + yield* Effect.sync(() => { + process.stderr.write(render(error, command)) + process.exitCode = 1 + }) + }) +} + +export function render(error: Service.StartError, command: string) { + const detail = + error.stage === "spawn" + ? "The service process could not be started." + : error.stage === "registration" + ? "The service exited or never became ready.\nThe expected registration file was not created." + : "The service started but did not become ready." + return `\n${RED_BOLD}OpenCode could not start its background service${RESET}\n\n${detail}\n\n${BOLD}Try:${RESET}\n ${command} service restart\n OPENCODE_LOG_LEVEL=DEBUG ${command}\n` +} + +export * as StartupError from "./startup-error" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 75f798b0ebfd..b200664880f0 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { AppProcess } from "@opencode-ai/core/process" +import { StartupError } from "./framework/startup-error" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), @@ -51,6 +52,7 @@ Effect.logInfo("cli starting", { }).pipe( Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.annotateLogs({ role: "cli" }), + Effect.catchCause((cause) => StartupError.handle(cause, Commands.name)), Effect.provide(Updater.layer), Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(Observability.layer), diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index a793ce025744..1ddbd049ee7c 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -11,11 +11,72 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionTable } from "@opencode-ai/core/session/sql" import { expect, test } from "bun:test" -import { Effect, Schedule, Schema } from "effect" +import { Cause, Effect, Exit, Schedule, Schema } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { ServiceConfig } from "../src/services/service-config" +import { StartupError } from "../src/framework/startup-error" + +const RED_BOLD = "\x1b[91m\x1b[1m" +const BOLD = "\x1b[1m" +const RESET = "\x1b[0m" + +test("renders a missing registration as an actionable startup failure", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-start-")) + const registration = path.join(root, "server.json") + const script = path.join(root, "exit.ts") + await Bun.write(script, "process.exit(1)\n") + + try { + const exit = await Service.start({ file: registration, command: [process.execPath, script] }).pipe( + Effect.provide(NodeFileSystem.layer), + Effect.exit, + Effect.runPromise, + ) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Service.StartError) + if (error instanceof Service.StartError) { + expect(StartupError.render(error, "opencode2")).toBe( + `\n${RED_BOLD}OpenCode could not start its background service${RESET}\n\nThe service exited or never became ready.\nThe expected registration file was not created.\n\n${BOLD}Try:${RESET}\n opencode2 service restart\n OPENCODE_LOG_LEVEL=DEBUG opencode2\n`, + ) + } + expect(Cause.pretty(exit.cause)).toContain( + `[cause]: PlatformError: NotFound: FileSystem.readFile (${registration})`, + ) + } + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test("reports a service spawn failure without losing its cause", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-spawn-")) + const command = path.join(os.tmpdir(), "opencode-command-that-does-not-exist") + try { + const exit = await Service.start({ file: path.join(root, "server.json"), command: [command] }).pipe( + Effect.provide(NodeFileSystem.layer), + Effect.exit, + Effect.runPromise, + ) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Service.StartError) + if (error instanceof Service.StartError) { + expect(error.stage).toBe("spawn") + expect(StartupError.render(error, "opencode2")).toContain("The service process could not be started.") + } + expect(Cause.pretty(exit.cause)).toContain(command) + } + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) test("local channel stores service config with the local service filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index f047d462bf72..3f850b1c7162 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -1,5 +1,6 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect" import { spawn } from "node:child_process" +import { once } from "node:events" import { homedir } from "node:os" import { join } from "node:path" @@ -34,6 +35,11 @@ export type Options = { export type StartReason = "missing" | "version-mismatch" +export class StartError extends Schema.TaggedErrorClass()("ServiceStartError", { + stage: Schema.Literals(["spawn", "registration", "readiness"]), + cause: Schema.Defect(), +}) {} + export type StartOptions = Options & { // Called once when start() decides it must spawn: either no service was // found, or a healthy service with a different version is being replaced. @@ -66,19 +72,23 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore) const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] - if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) - const child = yield* Effect.try({ - try: () => { + if (command === undefined) + return yield* Effect.fail(new StartError({ stage: "spawn", cause: new Error("Missing service command") })) + const child = yield* Effect.tryPromise({ + try: async () => { const child = spawn(command, args, { detached: true, stdio: "ignore" }) + await once(child, "spawn") child.unref() return child }, - catch: (cause) => new Error("Failed to start server", { cause }), + catch: (cause) => new StartError({ stage: "spawn", cause }), }) - return yield* discoverLocal(options).pipe( + return yield* awaitReady(options).pipe( Effect.flatMap((found) => - found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found), + found === undefined + ? Effect.fail(new StartError({ stage: "readiness", cause: new Error("Server is not ready") })) + : Effect.succeed(found), ), Effect.retry(poll), Effect.tap((found) => @@ -90,7 +100,6 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions ), Effect.map((found) => found.endpoint), Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), - Effect.mapError(() => new Error("Failed to start server")), ) }) @@ -135,6 +144,18 @@ const read = Effect.fnUntraced(function* (file?: string) { return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined)) }) +const awaitReady = Effect.fnUntraced(function* (options: Options) { + const fs = yield* FileSystem.FileSystem + const info = yield* fs.readFileString(options.file ?? fallback()).pipe( + Effect.mapError( + (cause) => new StartError({ stage: cause.reason._tag === "NotFound" ? "registration" : "readiness", cause }), + ), + Effect.flatMap(decode), + Effect.mapError((cause) => (cause instanceof StartError ? cause : new StartError({ stage: "readiness", cause }))), + ) + return yield* probe(info, options.version) +}) + type LocalService = { readonly info: Info readonly endpoint: Endpoint