fix(cli): restart stale clients after updates

This commit is contained in:
Kit Langton 2026-07-05 13:31:19 -04:00
commit 910af9a122
13 changed files with 246 additions and 25 deletions

View file

@ -1,4 +1,4 @@
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { Data, Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
@ -23,6 +23,9 @@ export type Options = {
// When set, discovery only returns a server reporting this exact version,
// and start() replaces a healthy server whose version differs.
readonly version?: string
// Decides whether start() may terminate a healthy version-mismatched server.
// Defaults to true for callers that do not need directional version handling.
readonly canReplace?: (version: string | undefined) => boolean
// Argv used to spawn the service. Defaults to ["opencode", "serve",
// "--service"] resolved from PATH.
readonly command?: ReadonlyArray<string>
@ -44,7 +47,11 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
const compatible = yield* discover(options)
if (compatible !== undefined) return compatible
const mismatched = yield* find(options)
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
if (mismatched !== undefined) {
const error = replacementError(mismatched.info, options)
if (error) return yield* Effect.fail(error)
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"))
@ -67,8 +74,12 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
export const stop = Effect.fn("service.stop")(function* (options: Options = {}) {
const fs = yield* FileSystem.FileSystem
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing.info, options)
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
if (existing !== undefined) {
const error = replacementError(existing.info, options)
if (error) return yield* Effect.fail(error)
yield* kill(existing.info, options)
}
return yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
})
function fallback() {
@ -89,6 +100,22 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type
export class VersionMismatchError extends Data.TaggedError("ServiceVersionMismatchError")<{
readonly clientVersion: string | undefined
readonly serverVersion: string | undefined
readonly message: string
}> {}
function replacementError(info: Info, options: Options): VersionMismatchError | undefined {
if (options.version === undefined || info.version === options.version || options.canReplace?.(info.version) !== false)
return undefined
return new VersionMismatchError({
clientVersion: options.version,
serverVersion: info.version,
message: `Client version ${options.version} cannot replace server version ${info.version ?? "unknown"}`,
})
}
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
// A missing or corrupt file means no valid info; callers treat both

View file

@ -0,0 +1,49 @@
import { NodeFileSystem } from "@effect/platform-node"
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Service } from "../src/effect/index"
const cleanup: Array<() => void | Promise<void>> = []
afterEach(async () => {
await Promise.all(cleanup.splice(0).map(async (run) => await run()))
})
test("does not start or stop a healthy service rejected by the version policy", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-client-service-"))
const child = Bun.spawn([process.execPath, "-e", "await new Promise(() => {})"])
const server = Bun.serve({ port: 0, fetch: () => Response.json({ healthy: true }) })
const file = path.join(root, "service.json")
cleanup.push(
() => child.kill(),
() => server.stop(true),
() => fs.rm(root, { recursive: true, force: true }),
)
await Bun.write(file, JSON.stringify({ id: "newer", version: "2.0.0", url: server.url.toString(), pid: child.pid }))
const options = {
file,
version: "1.0.0",
canReplace: () => false,
command: [process.execPath, "-e", "throw new Error('should not spawn')"],
}
const startError = await Service.start(options).pipe(
Effect.flip,
Effect.provide(NodeFileSystem.layer),
Effect.runPromise,
)
const stopError = await Service.stop(options).pipe(
Effect.flip,
Effect.provide(NodeFileSystem.layer),
Effect.runPromise,
)
expect(startError).toBeInstanceOf(Service.VersionMismatchError)
expect(startError).toMatchObject({ clientVersion: "1.0.0", serverVersion: "2.0.0" })
expect(stopError).toBeInstanceOf(Service.VersionMismatchError)
expect(process.kill(child.pid, 0)).toBe(true)
expect(await Bun.file(file).exists()).toBe(true)
})