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
|
|
@ -6,8 +6,9 @@ const path = require("path")
|
|||
const os = require("os")
|
||||
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
|
||||
const restartExitCode = 75
|
||||
|
||||
function run(target) {
|
||||
function run(target, restarted = false) {
|
||||
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
|
||||
child.on("error", (error) => {
|
||||
console.error(error.message)
|
||||
|
|
@ -25,6 +26,7 @@ function run(target) {
|
|||
child.on("exit", (code, signal) => {
|
||||
for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal])
|
||||
if (signal) return process.kill(process.pid, signal)
|
||||
if (code === restartExitCode && !restarted) return run(target, true)
|
||||
process.exit(typeof code === "number" ? code : 0)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { ServiceConfig } from "../../services/service-config"
|
|||
import { Standalone } from "../../services/standalone"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
const restartExitCode = 75
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = Option.getOrUndefined(input.directory)
|
||||
|
|
@ -22,9 +24,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||
const password = yield* Env.password
|
||||
const explicit = {
|
||||
url: server,
|
||||
headers: password
|
||||
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
|
||||
: undefined,
|
||||
headers: password ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } : undefined,
|
||||
} satisfies Service.Transport
|
||||
// Fail loudly before entering the TUI: an explicit server that is
|
||||
// unreachable or rejects auth should not present as reconnect churn.
|
||||
|
|
@ -60,7 +60,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||
Effect.gen(function* () {
|
||||
const found = yield* Service.discover(serviceOptions)
|
||||
return found ?? (yield* Service.start(serviceOptions))
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
}).pipe(Effect.catchIf(versionMismatch, restart), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: () => Promise.resolve(transport)
|
||||
// Restart the managed service in place; start() resolves once the
|
||||
|
|
@ -76,11 +76,36 @@ export default Runtime.handler(Commands, (input) =>
|
|||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
: undefined
|
||||
yield* runTui(
|
||||
return yield* runTui(
|
||||
transport,
|
||||
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
discover,
|
||||
reload,
|
||||
() => {
|
||||
process.exitCode = restartExitCode
|
||||
},
|
||||
)
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.catchIf(versionMismatch, (error) =>
|
||||
restart(error).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
process.exitCode = restartExitCode
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function restart(error: Service.VersionMismatchError) {
|
||||
return Effect.logInfo("restarting stale client", {
|
||||
clientVersion: error.clientVersion,
|
||||
serverVersion: error.serverVersion,
|
||||
}).pipe(Effect.as({ restart: true as const }))
|
||||
}
|
||||
|
||||
function versionMismatch(error: unknown): error is Service.VersionMismatchError {
|
||||
return error instanceof Service.VersionMismatchError
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,6 @@ Effect.logInfo("cli starting", {
|
|||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
||||
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Service } from "@opencode-ai/client/effect"
|
|||
import { Effect, FileSystem, Schema } from "effect"
|
||||
import { randomBytes } from "crypto"
|
||||
import path from "path"
|
||||
import semver from "semver"
|
||||
|
||||
// The CLI's service configuration file, plus the Service.Options binding that
|
||||
// points the client package's service operations at this CLI: which
|
||||
|
|
@ -45,10 +46,21 @@ export const options = Effect.fnUntraced(function* () {
|
|||
return {
|
||||
file,
|
||||
version: InstallationVersion,
|
||||
canReplace: (version: string | undefined) => canReplaceVersion(version, InstallationVersion),
|
||||
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
|
||||
}
|
||||
})
|
||||
|
||||
export function canReplaceVersion(serverVersion: string | undefined, clientVersion: string) {
|
||||
if (serverVersion === undefined) return true
|
||||
// Preview versions end in `<channel>-<build>[.<attempt>]`. Convert the build
|
||||
// to a numeric semver identifier so next-15000 sorts after next-9999.
|
||||
const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
|
||||
const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
|
||||
if (!semver.valid(server) || !semver.valid(client)) return true
|
||||
return semver.lt(server, client)
|
||||
}
|
||||
|
||||
export const read = Effect.fn("cli.service-config.read")(function* () {
|
||||
const { fs, configFile } = yield* env
|
||||
return yield* fs.readFileString(configFile).pipe(
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import type { Args } from "@opencode-ai/tui/context/args"
|
|||
export function runTui(
|
||||
transport: Service.Transport,
|
||||
args: Args,
|
||||
discover?: () => Promise<Service.Transport>,
|
||||
discover?: () => Promise<Service.Transport | { restart: true }>,
|
||||
reload?: () => Promise<void>,
|
||||
restart?: () => void,
|
||||
) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
|
|
@ -32,12 +33,14 @@ export function runTui(
|
|||
discover: discover
|
||||
? async () => {
|
||||
const next = await discover()
|
||||
if ("restart" in next) return next
|
||||
return {
|
||||
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
|
||||
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
restart,
|
||||
reload,
|
||||
args,
|
||||
config,
|
||||
|
|
|
|||
6
packages/cli/test/fixture/restart-child.ts
Normal file
6
packages/cli/test/fixture/restart-child.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const stateFile = process.argv[2]
|
||||
const exit = Number(process.argv[3])
|
||||
const count = (await Bun.file(stateFile).exists()) ? Number(await Bun.file(stateFile).text()) : 0
|
||||
|
||||
await Bun.write(stateFile, String(count + 1))
|
||||
process.exit(exit || (count === 0 ? 75 : 0))
|
||||
43
packages/cli/test/launcher.test.ts
Normal file
43
packages/cli/test/launcher.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const launcher = path.join(import.meta.dir, "../bin/opencode2.cjs")
|
||||
const fixture = path.join(import.meta.dir, "fixture/restart-child.ts")
|
||||
|
||||
test("restarts the installed binary once with the original arguments", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-launcher-"))
|
||||
const state = path.join(root, "count")
|
||||
|
||||
try {
|
||||
const child = Bun.spawn([process.execPath, launcher, fixture, state, "0"], {
|
||||
env: { ...process.env, OPENCODE_BIN_PATH: process.execPath },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
expect(await child.exited).toBe(0)
|
||||
expect(await Bun.file(state).text()).toBe("2")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("bounds automatic restarts", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-launcher-"))
|
||||
const state = path.join(root, "count")
|
||||
|
||||
try {
|
||||
const child = Bun.spawn([process.execPath, launcher, fixture, state, "75"], {
|
||||
env: { ...process.env, OPENCODE_BIN_PATH: process.execPath },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
expect(await child.exited).toBe(75)
|
||||
expect(await Bun.file(state).text()).toBe("2")
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -7,6 +7,18 @@ import os from "node:os"
|
|||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
|
||||
test("only replaces older semantic versions", () => {
|
||||
expect(ServiceConfig.canReplaceVersion("1.0.0", "2.0.0")).toBe(true)
|
||||
expect(ServiceConfig.canReplaceVersion("2.0.0", "1.0.0")).toBe(false)
|
||||
expect(ServiceConfig.canReplaceVersion("1.0.0", "1.0.0")).toBe(false)
|
||||
expect(ServiceConfig.canReplaceVersion("0.0.0-next-9999", "0.0.0-next-15000")).toBe(true)
|
||||
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000", "0.0.0-next-9999")).toBe(false)
|
||||
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.1", "0.0.0-next-15000.2")).toBe(true)
|
||||
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.10", "0.0.0-next-15000.9")).toBe(false)
|
||||
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.10", "0.0.0-next-15000.10")).toBe(false)
|
||||
expect(ServiceConfig.canReplaceVersion(undefined, "1.0.0")).toBe(true)
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue