fix(desktop): recover failed beta servers and clean unused CLI stages (#57321)

This commit is contained in:
Kit Langton
2026-10-20 10:41:19 -07:00
committed by GitHub
Unverified
parent 547381ad0c
commit e5e4f4b2b5
8 changed files with 90 additions and 254 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import { PtyHandoff } from "../pty-handoff.js"
import { decide, fallback, headers, probeResult, same } from "../service-probe.js"
export * from "../service.js"
export { headers }
export { fallback, headers }
// Find, start, and stop the local opencode background service.
//
@@ -1,15 +1,17 @@
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"
import { CHANNEL } from "../constants"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages } from "./cli-stages"
import { serviceVersion, shouldCleanStages } from "./cli-version"
import { keptStages, serviceVersion, shouldRetryEnsure } from "./cli-version"
import { DesktopCli } from "./desktop-cli"
import { SidecarCredentials } from "./sidecar-credentials"
import { sidecarProbe } from "./sidecar-probe"
export * as BackgroundService from "./background-service"
const RegisteredService = Schema.Struct({ version: Schema.optional(Schema.String) })
export interface Interface {
readonly connection: Effect.Effect<SidecarCredentials.Data>
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
@@ -33,24 +35,26 @@ export const layer = Layer.effect(
const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial" | "reconnect") {
yield* Effect.logInfo("starting v2 background service")
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const desktopCli = yield* DesktopCli.Service
const runFork = Effect.runForkWith(yield* Effect.context<FileSystem.FileSystem | Path.Path>())
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const cli = yield* desktopCli.resolve
const version = serviceVersion({ channel: CHANNEL, isolated, mode, version: cli.version })
let started = false
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const client = yield* Effect.promise(() => import("@opencode/client/service"))
const ensure = () =>
const file =
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined
const ensure = (required = version) =>
client.Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
file,
version: required,
// A fixed port makes a second contender fail to bind and back off; port 0 never collides, so two
// services could boot against the same database.
command: [
@@ -59,37 +63,53 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
"--service",
...(isolated ? ["--hostname", "0.0.0.0", "--port", String(0x0c0c)] : []),
],
onStart: (reason, previousVersion) => {
started = true
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion }))
},
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
})
const runEnsure = () =>
ensure().catch((error) => {
if (!shouldRetryEnsure(version, error instanceof Error ? error.message : String(error))) throw error
return ensure(cli.version)
})
// A compatible service the entry module already found is adopted at once; ensure() still runs
// afterwards for its side effects (terminal handoff completion), off the renderer's path.
const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined
if (early) yield* Effect.sync(() => void ensure().catch(() => undefined))
const service = early ?? (yield* Effect.tryPromise(ensure))
if (early) yield* Effect.sync(() => void runEnsure().catch(() => undefined))
const service = early ?? (yield* Effect.tryPromise(runEnsure))
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
const runningVersion = yield* fs.readFileString(file ?? client.fallback()).pipe(
Effect.map(
(text) =>
Option.getOrUndefined(Schema.decodeUnknownOption(Schema.fromJsonString(RegisteredService))(text))?.version,
),
Effect.orElseSucceed(() => undefined),
)
yield* Effect.logInfo("v2 CLI background service ready", {
version,
runningVersion,
probed: !!early,
...endpoint(url.origin),
})
// Only packaged and isolated builds run a staged copy; any other binary sits in the source tree.
// Beta adopts an existing service across versions, so its stage stays until a new service starts.
if (shouldCleanStages({ isolated, mode, packaged: app.isPackaged, started, version }) && cli.binary)
// Keep both the bundled stage and any adopted server's stage so its executable stays on disk.
if (mode === "initial" && (app.isPackaged || isolated) && cli.binary && runningVersion)
runFork(
cleanStages(cli.binary).pipe(
cleanStages(cli.binary, { keep: keptStages(cli.version, runningVersion) }).pipe(
Effect.catch((error) => Effect.logError("failed to clean staged v2 CLIs", { error })),
),
)
const ready = { url: url.origin, password: service.auth.password } satisfies SidecarCredentials.Data
SidecarCredentials.set(ready)
@@ -12,8 +12,8 @@ describe("cleanStages", () => {
{ name: "upgrade", staged: ["2.0.18", "2.0.19", "2.0.20"], current: "2.0.20" },
{ name: "downgrade", staged: ["2.0.18", "2.0.19", "2.0.20"], current: "2.0.18" },
{ name: "prerelease", staged: ["2.0.20", "2.1.0-beta.1", "local"], current: "2.1.0-beta.1" },
{ name: "only current", staged: ["2.0.20"], current: "2.0.20" },
])("keeps only the current staged CLI after $name", async (input) => {
{ name: "adopted server", staged: ["2.0.18", "2.0.19", "2.0.20"], current: "2.0.20", keep: ["2.0.19"] },
])("keeps only the current and adopted staged CLIs after $name", async (input) => {
const root = mkdtempSync(path.join(tmpdir(), "opencode-cli-stages-"))
try {
@@ -24,12 +24,12 @@ describe("cleanStages", () => {
writeFileSync(path.join(root, "unrelated"), "")
await Effect.runPromise(
cleanStages(path.join(root, input.current, "opencode-cli")).pipe(
cleanStages(path.join(root, input.current, "opencode-cli"), { keep: input.keep }).pipe(
Effect.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
),
)
expect(readdirSync(root).sort()).toEqual([input.current, "unrelated"])
expect(readdirSync(root).sort()).toEqual([input.current, ...(input.keep ?? []), "unrelated"].sort())
expect(readdirSync(path.join(root, input.current))).toEqual(["opencode-cli"])
} finally {
rmSync(root, { recursive: true, force: true })
@@ -1,20 +1,23 @@
import { Effect, FileSystem, Path } from "effect"
// Each version and architecture stages its own copy of the CLI, and only the current one is ever started
// again: a downgrade stages its copy anew. Windows refuses to delete an executable that has not exited yet,
// so a failed removal is logged and retried on the next launch.
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
// Each version and architecture stages its own copy of the CLI, and only the current one (plus any adopted
// beta server still running) is kept: a downgrade stages its copy anew. Windows refuses to delete an executable
// that has not exited yet, so a failed removal is logged and retried on the next launch.
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (
binary: string,
options?: { readonly keep?: ReadonlyArray<string> },
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const current = path.dirname(binary)
const root = path.dirname(current)
const keep = new Set([path.basename(current), ...(options?.keep ?? [])])
const entries = yield* fs.readDirectory(root)
yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
if (keep.has(entry)) return
const target = path.join(root, entry)
if (target === current) return
const stat = yield* fs.stat(target).pipe(Effect.orElseSucceed(() => undefined))
if (stat?.type !== "Directory") return
@@ -1,227 +1,33 @@
import { describe, expect, test } from "bun:test"
import { Predicate } from "effect"
import { decide, probeResult, type LocalService } from "../../../../client/src/service-probe"
import { initializationData } from "../../renderer/startup/initialization"
import { parseCliVersion, serviceVersion, shouldCleanStages } from "./cli-version"
const protocolMessage =
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service."
function registered(input: Pick<LocalService, "state" | "compatible" | "version">): LocalService {
return {
info: { url: "http://127.0.0.1:4096", pid: 4242, version: input.version },
endpoint: { url: "http://127.0.0.1:4096" },
...input,
}
}
import { keptStages, serviceVersion, shouldRetryEnsure } from "./cli-version"
describe("cli-version", () => {
test.each([
{ output: "opencode v2.0.32", expected: "2.0.32" },
{ output: "opencode-cli v0.0.0-beta-21153", expected: "0.0.0-beta-21153" },
{ output: "2.0.32", expected: "2.0.32" },
])("parses CLI version output ($output)", (input) => {
expect(parseCliVersion(input.output)).toBe(input.expected)
})
test("rejects empty CLI version output", () => {
expect(() => parseCliVersion("")).toThrow("V2 CLI did not provide a version")
})
test.each([
{
name: "beta initial adopts any compatible version",
channel: "beta" as const,
isolated: false,
mode: "initial" as const,
version: "0.0.0-beta-21154",
expected: undefined,
},
{
name: "beta isolated dev server enforces the worktree version",
channel: "beta" as const,
isolated: true,
mode: "initial" as const,
version: "0.0.0-beta-21154",
expected: "0.0.0-beta-21154",
},
{
name: "prod initial enforces the bundled version",
channel: "prod" as const,
isolated: false,
mode: "initial" as const,
version: "2.0.32",
expected: "2.0.32",
},
{
name: "dev initial enforces the bundled version",
channel: "dev" as const,
isolated: false,
mode: "initial" as const,
version: "2.0.32-dev",
expected: "2.0.32-dev",
},
{
name: "local initial enforces the bundled version",
channel: "local" as const,
isolated: false,
mode: "initial" as const,
version: "2.0.0-local",
expected: "2.0.0-local",
},
{
name: "prod reconnect adopts any compatible version",
channel: "prod" as const,
isolated: false,
mode: "reconnect" as const,
version: "2.0.32",
expected: undefined,
},
{
name: "beta reconnect adopts any compatible version",
channel: "beta" as const,
isolated: false,
mode: "reconnect" as const,
version: "0.0.0-beta-21154",
expected: undefined,
},
])("$name", (input) => {
{ channel: "beta" as const, isolated: false, mode: "initial" as const, version: "0.0.0-beta-2", expected: undefined },
{ channel: "beta" as const, isolated: true, mode: "initial" as const, version: "0.0.0-beta-2", expected: "0.0.0-beta-2" },
{ channel: "prod" as const, isolated: false, mode: "initial" as const, version: "2.0.32", expected: "2.0.32" },
])("serviceVersion($channel, isolated=$isolated) -> $expected", (input) => {
expect(serviceVersion(input)).toBe(input.expected)
})
test.each([
{
name: "beta adopts a healthy registered service running an older version",
channel: "beta" as const,
bundled: "0.0.0-beta-21154",
service: registered({ version: "0.0.0-beta-21150", state: "ready", compatible: true }),
expected: { _tag: "reuse" as const },
},
{
name: "beta adopts a healthy registered service running the same version",
channel: "beta" as const,
bundled: "0.0.0-beta-21154",
service: registered({ version: "0.0.0-beta-21154", state: "ready", compatible: true }),
expected: { _tag: "reuse" as const },
},
{
name: "beta adopts a healthy registered service running a newer version",
channel: "beta" as const,
bundled: "0.0.0-beta-21154",
service: registered({ version: "0.0.0-beta-21160", state: "ready", compatible: true }),
expected: { _tag: "reuse" as const },
},
{
name: "beta waits for a starting registered service regardless of version",
channel: "beta" as const,
bundled: "0.0.0-beta-21154",
service: registered({ version: "0.0.0-beta-21150", state: "waiting", compatible: true }),
expected: { _tag: "wait" as const },
},
{
name: "beta surfaces an in-app message instead of killing a protocol-incompatible service",
channel: "beta" as const,
bundled: "0.0.0-beta-21154",
service: registered({ version: "0.0.0-beta-21150", state: "ready", compatible: false }),
expected: { _tag: "fail" as const, error: new Error(protocolMessage) },
},
{
name: "prod adopts a healthy registered service with the matching version",
channel: "prod" as const,
bundled: "2.0.32",
service: registered({ version: "2.0.32", state: "ready", compatible: true }),
expected: { _tag: "reuse" as const },
},
{
name: "prod replaces a healthy registered service with an older version",
channel: "prod" as const,
bundled: "2.0.32",
service: registered({ version: "2.0.31", state: "ready", compatible: true }),
expected: { _tag: "replace" as const, pty: "handoff" as const },
},
{
name: "prod replaces a healthy registered service with a newer version",
channel: "prod" as const,
bundled: "2.0.32",
service: registered({ version: "2.0.33", state: "ready", compatible: true }),
expected: { _tag: "replace" as const, pty: "handoff" as const },
},
{
name: "prod replaces an unhealthy failed service from another version",
channel: "prod" as const,
bundled: "2.0.32",
service: registered({ version: "2.0.31", state: "failed", compatible: true }),
expected: { _tag: "replace" as const, pty: "clear" as const },
},
{
name: "prod replaces a protocol-incompatible service when its version differs",
channel: "prod" as const,
bundled: "2.0.32",
service: registered({ version: "2.0.31", state: "ready", compatible: false }),
expected: { _tag: "replace" as const, pty: "handoff" as const },
},
{
name: "prod surfaces an in-app message when a matching-version service is protocol-incompatible",
channel: "prod" as const,
bundled: "2.0.32",
service: registered({ version: "2.0.32", state: "ready", compatible: false }),
expected: { _tag: "fail" as const, error: new Error(protocolMessage) },
},
])("$name", (input) => {
const version = serviceVersion({ channel: input.channel, mode: "initial", version: input.bundled })
const outcome = decide(input.service, { version })
expect(outcome).toEqual(input.expected)
if (Predicate.isTagged(outcome, "fail")) {
expect(() => initializationData(Object.assign(() => undefined, { error: outcome.error }))).toThrow(
protocolMessage,
)
}
})
test("an unreachable registered service probes as missing so ensure replaces it", async () => {
const probed = await probeResult({ url: "http://127.0.0.1:1", pid: 4242, version: "0.0.0-beta-21150" }, 200)
expect(probed.service).toBeUndefined()
{ bundled: "0.0.0-beta-2", running: "0.0.0-beta-1", expected: ["0.0.0-beta-2-arm64", "0.0.0-beta-1-arm64"] },
{ bundled: "0.0.0-beta-2", running: "0.0.0-beta-2", expected: ["0.0.0-beta-2-arm64"] },
{ bundled: "2.0.32", running: undefined, expected: ["2.0.32-arm64"] },
])("keptStages($bundled, $running)", (input) => {
expect(keptStages(input.bundled, input.running, "arm64")).toEqual(input.expected)
})
test.each([
{ version: undefined, message: "Background service failed to start", expected: true },
{ version: undefined, message: "Timed out waiting for the background service to start", expected: true },
{
name: "beta keeps staged CLIs when adopting an existing server",
isolated: false,
mode: "initial" as const,
packaged: true,
started: false,
version: undefined,
message: "Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
expected: false,
},
{
name: "beta cleans staged CLIs when it starts a new server",
isolated: false,
mode: "initial" as const,
packaged: true,
started: true,
version: undefined,
expected: true,
},
{
name: "prod cleans staged CLIs on initial launch",
isolated: false,
mode: "initial" as const,
packaged: true,
started: false,
version: "2.0.32",
expected: true,
},
{
name: "unpackaged non-isolated builds never clean stages",
isolated: false,
mode: "initial" as const,
packaged: false,
started: true,
version: "2.0.32",
expected: false,
},
])("$name", (input) => {
expect(shouldCleanStages(input)).toBe(input.expected)
{ version: "2.0.32", message: "Background service failed to start", expected: false },
])("shouldRetryEnsure($version, $message) -> $expected", (input) => {
expect(shouldRetryEnsure(input.version, input.message)).toBe(input.expected)
})
})
@@ -20,14 +20,24 @@ export function serviceVersion(input: {
return input.version
}
export function shouldCleanStages(input: {
readonly isolated?: boolean
readonly mode: "initial" | "reconnect"
readonly packaged: boolean
readonly started: boolean
readonly version: string | undefined
}) {
if (!input.packaged && !input.isolated) return false
// Retrying on timeout can also replace a server stuck in a migration longer than 120 s, which is accepted.
export function shouldRetryEnsure(version: string | undefined, message: string) {
if (version !== undefined) return false
return input.started || (input.mode === "initial" && input.version !== undefined)
return (
message === "Background service failed to start" ||
message === "Timed out waiting for the background service to start"
)
}
export function stageName(version: string, arch = process.arch) {
return `${version}-${arch}`.replace(/[^a-zA-Z0-9._-]/g, "-")
}
export function keptStages(bundledVersion: string, runningVersion?: string, arch = process.arch) {
const stages = [stageName(bundledVersion, arch)]
if (runningVersion && runningVersion !== bundledVersion) stages.push(stageName(runningVersion, arch))
return stages
}
@@ -9,7 +9,7 @@ import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { parseCliVersion } from "./cli-version"
import { parseCliVersion, stageName } from "./cli-version"
const execFileAsync = promisify(execFile)
@@ -140,7 +140,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
// x64 and ARM64 installers share userData on Windows ARM64, so a same-version switch must not reuse the other copy.
const stage = `${version}-${process.arch}`.replace(/[^a-zA-Z0-9._-]/g, "-")
const stage = stageName(version)
const directory = path.join(app.getPath("userData"), "cli", stage)
const destination = path.join(directory, executableName())
@@ -3,7 +3,6 @@ import path from "node:path"
import { app } from "electron"
import type { Endpoint } from "@opencode/client/service"
import { CHANNEL } from "../constants"
import { serviceVersion } from "./cli-version"
// The main thread idles between showing the first window and evaluating the main bundle, waiting
// for the renderer's asset requests. That slot is long enough to find out whether a compatible
@@ -14,7 +13,7 @@ let probe: Promise<Endpoint | undefined> | undefined
export function startSidecarProbe() {
if (!app.isPackaged) return
const version = serviceVersion({ channel: CHANNEL, mode: "initial", version: bundledVersion() })
const version = CHANNEL === "beta" ? undefined : bundledVersion()
if (version === "") return
probe = import("@opencode/client/service")
@@ -27,8 +26,6 @@ export function sidecarProbe() {
}
function bundledVersion() {
if (CHANNEL === "beta") return ""
try {
return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim()
} catch {