Compare commits

...
Author SHA1 Message Date
Kit Langton 3b03692aef fix(cli): preserve server startup failure cause 2026-07-11 22:10:21 -04:00
4 changed files with 122 additions and 8 deletions
@@ -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
View File
@@ -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),
+62 -1
View File
@@ -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-"))
+28 -7
View File
@@ -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<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.
@@ -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