Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/cli/src/framework/startup-error.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>, 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"
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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),
Expand Down
63 changes: 62 additions & 1 deletion packages/cli/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"))
Expand Down
35 changes: 28 additions & 7 deletions packages/client/src/effect/service.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -34,6 +35,11 @@ export type Options = {

export type StartReason = "missing" | "version-mismatch"

export class StartError extends Schema.TaggedErrorClass<StartError>()("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.
Expand Down Expand Up @@ -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) =>
Expand All @@ -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")),
)
})

Expand Down Expand Up @@ -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
Expand Down
Loading