fix(cli): restart stale clients after updates
This commit is contained in:
parent
f9d1d3b259
commit
910af9a122
13 changed files with 246 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
49
packages/client/test/service.test.ts
Normal file
49
packages/client/test/service.test.ts
Normal 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)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue