Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 2a0ccdbfd4 refactor(cli): simplify service replacement policy 2026-08-12 12:45:43 -04:00
Kit Langton 88a9ee5d11 fix(cli): confirm managed service downgrades 2026-08-12 12:19:58 -04:00
Kit Langton bf3ca452b6 fix(cli): harden managed service replacement 2026-08-12 12:18:54 -04:00
17 changed files with 493 additions and 220 deletions
+4
View File
@@ -188,12 +188,14 @@
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"semver": "catalog:",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
@@ -6348,6 +6350,8 @@
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/client/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
"@opencode-ai/console-app/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
@@ -8,7 +8,7 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
const options = yield* ServiceConfig.options({ checkVersion: true })
yield* Service.stop(options)
const transport = yield* Service.ensure(options)
process.stdout.write(transport.url + EOL)
+12 -3
View File
@@ -1,4 +1,4 @@
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
import { Service, VersionMismatchError, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { OPENCODE_VERSION } from "../version"
import { Effect, Redacted } from "effect"
@@ -57,13 +57,22 @@ function managedService(options: EnsureOptions) {
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.ensure(reconnectOptions)
yield* Service.ensure(options)
}),
}
}
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "replace")
return yield* Service.ensure(options).pipe(
Effect.mapError((error) =>
error instanceof VersionMismatchError
? new Error(`${error.message}. Run \`opencode2 service restart\` to activate this installed version.`, {
cause: error,
})
: error,
),
)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
const compatible = yield* Service.discover(options)
@@ -104,6 +104,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
canReplace: (version: string | undefined) => Service.canReplaceVersion(version, OPENCODE_VERSION),
command: [...selfCommand(), "serve", "--service"],
}
})
@@ -447,7 +447,7 @@ function UpdateFooter(props: {
})
return (
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
<Monogram ink={monogramInk} />
<box flexDirection="column" flexGrow={1} overflow="hidden">
<CellLine cells={header()} />
@@ -69,3 +69,34 @@ test("service options only require a matching version when requested", async ()
await fs.rm(root, { recursive: true, force: true })
}
})
test("normal launch refuses to replace a newer managed service", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-newer-service-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const registration = path.join(root, "state", ServiceConfig.filename())
using server = Bun.serve({
port: 0,
fetch() {
return Response.json({ healthy: true, version: "999.0.0", pid: process.pid })
},
})
try {
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(
registration,
JSON.stringify({ id: "newer-service", version: "999.0.0", url: server.url.toString(), pid: process.pid }),
)
await expect(
Effect.runPromise(
ServerConnection.resolve({ mismatch: "replace" }).pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
),
).rejects.toThrow("Run `opencode2 service restart` to activate this installed version")
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
+30 -1
View File
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Service, canReplaceVersion, type Info } from "@opencode-ai/client/effect/service"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
@@ -47,6 +47,35 @@ test("service filenames share release channels and identify preview channels", (
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
})
test("only newer clients replace managed service versions", () => {
expect(canReplaceVersion("0.0.0-next-17271", "0.0.0-next-17272")).toBe(true)
expect(canReplaceVersion("0.0.0-next-17272", "0.0.0-next-17271")).toBe(false)
expect(canReplaceVersion("0.0.0-next-17272", "0.0.0-next-17272")).toBe(false)
expect(canReplaceVersion(undefined, "0.0.0-next-17272")).toBe(false)
expect(canReplaceVersion("development-a", "development-b")).toBe(false)
expect(canReplaceVersion("development-b", "development-a")).toBe(false)
})
test("managed version replacement can never be mutual", () => {
const versions = [
undefined,
"1.0.0",
"1.0.1",
"0.0.0-next-9999",
"0.0.0-next-15000",
"0.0.0-next-15000.1",
"0.0.0-next-15000.2",
"development-a",
"development-b",
]
for (const left of versions) {
for (const right of versions) {
if (left === undefined || right === undefined) continue
expect(canReplaceVersion(left, right) && canReplaceVersion(right, left)).toBe(false)
}
}
})
test("service config migrates from the hashed channel filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
+3 -1
View File
@@ -33,7 +33,8 @@
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/protocol": "workspace:*"
"@opencode-ai/protocol": "workspace:*",
"semver": "catalog:"
},
"peerDependencies": {
"effect": "4.0.0-beta.101"
@@ -49,6 +50,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"@types/semver": "catalog:",
"effect": "catalog:"
}
}
+41 -87
View File
@@ -2,7 +2,14 @@ import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import {
canReplaceVersion,
VersionMismatchError,
type DiscoverOptions,
type Endpoint,
type EnsureOptions,
type StopOptions,
} from "../service.js"
import {
contenderFailure,
contenderFinished,
@@ -36,7 +43,7 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
options: DiscoverOptions & { readonly url: string },
) {
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
const found = info === undefined ? undefined : yield* probeResult({ ...info, url: options.url })
if (found === undefined || found.legacy) return undefined
if (!matchesVersion(found.version, options)) return undefined
return { endpoint: found.endpoint, state: found.state }
@@ -56,7 +63,6 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const timing = ensureTiming(options)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -80,18 +86,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const registration = yield* registered(options.file, true, timing.requestTimeout)
const info = registration.info
const service = registration.service
if (registration.timedOut && info !== undefined) {
timeouts = {
info,
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
yield* announce("missing")
yield* evict(info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && matchesVersion(service.version, options)
@@ -99,8 +93,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
if (options.canReplace?.(service.version) !== true)
return yield* Effect.fail(
new VersionMismatchError(typeof options.version === "string" ? options.version : undefined, service.version),
)
yield* kill(service, timing)
yield* announce("version-mismatch", service.version)
yield* kill(service, options, timing).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -133,8 +131,12 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
const registration = yield* registered(options.file, true)
if (registration.service !== undefined) yield* kill(registration.service, defaultEnsureTiming)
if (registration.service === undefined && registration.info !== undefined)
return yield* Effect.fail(
new Error("Background service is not responding; stop its process manually and try again"),
)
})
function fallback() {
@@ -178,10 +180,6 @@ type LocalService = {
readonly legacy: boolean
}
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
return (yield* probeResult(info, allowLegacy)).service
})
const probeResult = Effect.fnUntraced(function* (
info: Info,
allowLegacy = false,
@@ -206,57 +204,40 @@ const probeResult = Effect.fnUntraced(function* (
(cause: unknown) => ({ cause }),
),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
if ("cause" in result) return undefined
const response = result.value.response
const body = result.value.body
const health = decodeHealth(body)
if (Option.isSome(health)) {
if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && health.value.version !== info.version)
return { service: undefined, timedOut: false }
if (health.value.pid !== info.pid) return undefined
if (info.version !== undefined && health.value.version !== info.version) return undefined
return {
service: {
info,
endpoint,
version: health.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService,
timedOut: false,
}
info,
endpoint,
version: health.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService
}
if (
!allowLegacy ||
Option.isNone(decodeLegacyHealth(body)) ||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
)
return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
}
return undefined
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
})
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false, timeout?: number) {
const info = yield* read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
if (info === undefined) return { info: undefined, service: undefined }
return { info, service: yield* probeResult(info, allowLegacy, timeout) }
})
// Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
return (yield* registered(options.file, true)).service
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window.
// Poll until an authenticated stop exits, bounded by the configured stop window.
const poll = (timing: EnsureTiming) =>
Schedule.max([Schedule.spaced(timing.stopPollInterval), Schedule.recurs(timing.stopPollAttempts)])
const signal = (pid: number, name: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
const stopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
@@ -265,44 +246,16 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = yield* read(options.file)
if (current === undefined || !same(current, info)) return
yield* signal(info.pid, "SIGTERM")
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* read(options.file)
if (latest === undefined || !same(latest, info)) return
yield* signal(info.pid, "SIGKILL")
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
})
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
timing: EnsureTiming,
) {
const kill = Effect.fnUntraced(function* (service: LocalService, timing: EnsureTiming) {
const requested = yield* requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "rejected") return yield* Effect.fail(new Error("Background service rejected the stop request"))
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options)
if (current === undefined || !same(current.info, service.info)) return
yield* signal(service.info.pid, "SIGTERM")
return yield* Effect.fail(new Error("Background service does not support authenticated stop requests"))
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
if (Option.isSome(done)) return
const latest = yield* find(options)
if (latest === undefined || !same(latest.info, service.info)) return
yield* signal(service.info.pid, "SIGKILL")
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
return yield* Effect.fail(new Error("Background service accepted the stop request but did not exit"))
})
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
@@ -317,7 +270,8 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
signal: AbortSignal.timeout(timeout),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
if (response === undefined) return "rejected" as const
if (response.status === 404 || response.status === 405) return "unsupported" as const
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const decoded = decodeStopResponse(body)
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
@@ -325,4 +279,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout
})
/** Effect-based local service lifecycle operations. */
export const Service = { discover, incumbent, ensure, stop, headers, Info }
export const Service = { discover, incumbent, ensure, stop, headers, canReplaceVersion, Info }
+71 -86
View File
@@ -1,7 +1,15 @@
import { readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import {
canReplaceVersion,
VersionMismatchError,
type DiscoverOptions,
type Endpoint,
type Info,
type EnsureOptions,
type StopOptions,
} from "../service.js"
import {
contenderFailure,
contenderFinished,
@@ -10,7 +18,7 @@ import {
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
import type { ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -37,7 +45,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -61,19 +68,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
while (true) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, true, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
info: registration.info,
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
}
if (timeouts.count >= 3) {
announce("missing")
await evict(registration.info, options, timing)
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
@@ -81,8 +75,13 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
if (options.canReplace?.(service.version) !== true)
throw new VersionMismatchError(
typeof options.version === "string" ? options.version : undefined,
service.version,
)
await kill(service, timing)
announce("version-mismatch", service.version)
await kill(service, options, timing).catch(() => undefined)
lastSpawn = 0
}
} else {
@@ -110,8 +109,10 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
const registration = await registered(options.file, true)
if (registration.service !== undefined) await kill(registration.service, defaultEnsureTiming)
if (registration.service === undefined && registration.info !== undefined)
throw new Error("Background service is not responding; stop its process manually and try again")
}
function fallback() {
@@ -130,7 +131,15 @@ async function read(file?: string) {
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
if (text === undefined) return undefined
try {
return JSON.parse(text) as Info
const value: unknown = JSON.parse(text)
if (typeof value !== "object" || value === null) return undefined
if (!("url" in value) || typeof value.url !== "string") return undefined
if (!("pid" in value) || !Number.isInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0)
return undefined
if ("id" in value && value.id !== undefined && typeof value.id !== "string") return undefined
if ("version" in value && value.version !== undefined && typeof value.version !== "string") return undefined
if ("password" in value && value.password !== undefined && typeof value.password !== "string") return undefined
return value as Info
} catch {
return undefined
}
@@ -144,10 +153,6 @@ type LocalService = {
readonly legacy: boolean
}
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
return (await probeResult(info, allowLegacy)).service
}
async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
@@ -163,50 +168,54 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
})
.then(async (response) => ({
response,
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
body: (await response.json()) as unknown,
}))
.then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
if ("cause" in result) return undefined
const response = result.value.response
const body = result.value.body
if (body !== undefined && "version" in body && "pid" in body) {
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && body.version !== info.version) return { service: undefined, timedOut: false }
if (
typeof body === "object" &&
body !== null &&
"healthy" in body &&
body.healthy === true &&
"version" in body &&
typeof body.version === "string" &&
"pid" in body &&
typeof body.pid === "number" &&
Number.isInteger(body.pid) &&
body.pid > 0
) {
if (body.pid !== info.pid) return undefined
if (info.version !== undefined && body.version !== info.version) return undefined
return {
service: {
info,
endpoint,
version: body.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService,
timedOut: false,
}
}
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
return {
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
timedOut: false,
info,
endpoint,
version: body.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} satisfies LocalService
}
if (
!allowLegacy ||
typeof body !== "object" ||
body === null ||
!("healthy" in body) ||
body.healthy !== true ||
"version" in body ||
"pid" in body
)
return undefined
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
}
async function registered(file?: string, allowLegacy = false, timeout?: number) {
const info = await read(file)
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
}
async function find(options: { readonly file?: string }) {
return (await registered(options.file, true)).service
}
function signal(pid: number, name: NodeJS.Signals) {
try {
process.kill(pid, name)
} catch {}
if (info === undefined) return { info: undefined, service: undefined }
return { info, service: await probeResult(info, allowLegacy, timeout) }
}
function stopped(pid: number) {
@@ -226,37 +235,12 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
return false
}
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
const current = await read(options.file)
if (current === undefined || !same(current, info)) return
signal(info.pid, "SIGTERM")
if (await waitUntilStopped(info.pid, timing)) return
const latest = await read(options.file)
if (latest === undefined || !same(latest, info)) return
signal(info.pid, "SIGKILL")
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
}
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
async function kill(service: LocalService, timing: EnsureTiming) {
const requested = await requestStop(service, timing.requestTimeout)
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (requested === "rejected") throw new Error("Background service rejected the stop request")
if (requested === "unsupported") throw new Error("Background service does not support authenticated stop requests")
if (await waitUntilStopped(service.info.pid, timing)) return
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid, timing)))
throw new Error(`Server process ${service.info.pid} is still running`)
throw new Error("Background service accepted the stop request but did not exit")
}
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
@@ -267,7 +251,8 @@ async function requestStop(service: LocalService, timeout = defaultEnsureTiming.
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(timeout),
}).catch(() => undefined)
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
if (response === undefined) return "rejected" as const
if (response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
if (!response.ok || body?.accepted !== true) return "rejected" as const
return "accepted" as const
@@ -278,4 +263,4 @@ function delay(milliseconds: number) {
}
/** Promise-based local service lifecycle operations. */
export const Service = { discover, ensure, stop, headers }
export const Service = { discover, ensure, stop, headers, canReplaceVersion }
+11
View File
@@ -1,4 +1,5 @@
import type { DiscoverOptions } from "./service.js"
import semver from "semver"
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
if (options.version === undefined) return true
@@ -6,3 +7,13 @@ export function matchesVersion(version: string | undefined, options: DiscoverOpt
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
/** Whether a client version is strictly newer than a service version. */
export function canReplaceVersion(serverVersion: string | undefined, clientVersion: string) {
if (serverVersion === undefined) return false
// Compare preview build numbers numerically rather than as semver prerelease strings.
const server = serverVersion.replace(/^(0\.0\.0-.+)-(\d+(?:\.\d+)?)$/, "$1.$2")
const client = clientVersion.replace(/^(0\.0\.0-.+)-(\d+(?:\.\d+)?)$/, "$1.$2")
if (!semver.valid(server) || !semver.valid(client)) return false
return semver.lt(server, client)
}
+16
View File
@@ -1,3 +1,5 @@
export { canReplaceVersion } from "./service-version.js"
/** Connection details for a local OpenCode service. */
export type Endpoint = {
/** Base URL of the service. */
@@ -28,10 +30,24 @@ export type EnsureReason = "missing" | "version-mismatch"
export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Decide whether a version-mismatched service may be replaced. Defaults to false. */
readonly canReplace?: (version: string | undefined) => boolean
/** Called once before spawning a new service process. */
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
}
/** A healthy service exists, but the caller's replacement policy protects it. */
export class VersionMismatchError extends Error {
override readonly name = "VersionMismatchError"
constructor(
readonly clientVersion: string | undefined,
readonly serverVersion: string | undefined,
) {
super(`Background service ${serverVersion ?? "unknown"} does not match client ${clientVersion ?? "unknown"}`)
}
}
/** Options used to stop the local OpenCode service. */
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
+6 -4
View File
@@ -28,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
let requests = 0
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "old" || mode === "reject-stop" || mode === "stop-hanging") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
@@ -40,7 +40,11 @@ const server = Bun.serve({
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return Response.json({ accepted: false })
}
if (pathname === "/api/service/stop" && mode === "graceful") {
if (pathname === "/api/service/stop" && mode === "stop-hanging") {
await appendFile(registration + ".stop-attempts", process.pid + "\n")
return new Promise<Response>(() => {})
}
if (pathname === "/api/service/stop" && (mode === "graceful" || mode === "old" || mode === "incompatible")) {
const body = await request.json()
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
await writeFile(registration + ".stop", JSON.stringify(body))
@@ -63,8 +67,6 @@ const server = Bun.serve({
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
})
+92 -11
View File
@@ -38,6 +38,50 @@ test("discovers a compatible registered service", async () => {
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("rejects malformed registrations without probing or signaling", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const malformed = [
null,
[],
{},
{ url: "http://127.0.0.1:1" },
{ url: "http://127.0.0.1:1", pid: 0 },
{ url: "http://127.0.0.1:1", pid: -1 },
{ url: "http://127.0.0.1:1", pid: 1.5 },
{ url: "http://127.0.0.1:1", pid: "1" },
{ url: "http://127.0.0.1:1", pid: 1, id: 1 },
]
for (const value of malformed) {
await Bun.write(registration, JSON.stringify(value))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("rejects primitive and partial modern health responses", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const bodies = [
null,
1,
"healthy",
[],
{},
{ healthy: false, version: "test", pid: process.pid },
{ healthy: true, version: null, pid: process.pid },
{ healthy: true, version: "test", pid: "1" },
{ healthy: true, version: "test" },
{ healthy: true, pid: process.pid },
]
for (const body of bodies) {
using server = Bun.serve({ port: 0, fetch: () => Response.json(body) })
await Bun.write(registration, JSON.stringify({ url: server.url.toString(), pid: process.pid }))
expect(await Service.discover({ file: registration })).toBeUndefined()
}
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -100,7 +144,7 @@ test("reports a bounded contender stderr tail with native promises", async () =>
expect(error.message.length).toBeLessThan(9_000)
}, 10_000)
test("evicts an unresponsive registered service before starting its replacement", async () => {
test("never evicts an unresponsive registered service automatically", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
@@ -111,19 +155,45 @@ test("evicts an unresponsive registered service before starting its replacement"
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await ensure({
const options = {
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
})
const replacement = await Bun.file(registration).json()
command: [process.execPath, fixture, registration, "record-start"],
}
const result = ensure(options)
await waitForLines(registration + ".requests", 3)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
await expect(result).rejects.toThrow()
})
test("explicit native stop refuses to signal an unidentified unresponsive PID", async () => {
const registration = await setup("hanging")
const info = await Bun.file(registration).json()
await expect(Service.stop({ file: registration })).rejects.toThrow("stop its process manually")
expect(process.kill(info.pid, 0)).toBe(true)
})
test("a stale native client refuses to replace a newer service", async () => {
const registration = await setup("graceful")
const directory = await temp()
const contender = join(directory, "contender.json")
const info = await Bun.file(registration).json()
await expect(
ensure({
file: registration,
version: "old",
command: [process.execPath, fixture, contender, "record-start"],
}),
).rejects.toThrow("Background service test does not match client old")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(process.kill(info.pid, 0)).toBe(true)
expect(await Bun.file(registration).json()).toEqual(info)
})
test("requests graceful stop of the exact service instance", async () => {
@@ -156,3 +226,14 @@ async function waitForFile(file: string) {
}
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForLines(file: string, count: number) {
for (let attempt = 0; attempt < 600; attempt++) {
const text = await Bun.file(file)
.text()
.catch(() => "")
if (text.trim().split("\n").length >= count) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
}
+156 -24
View File
@@ -79,6 +79,7 @@ test("replaces an incompatible registered service", async () => {
ensure({
file: registration,
version: (version) => version.startsWith("2."),
canReplace: () => true,
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
onStart: (reason) => starts.push(reason),
}),
@@ -118,29 +119,39 @@ test("reports a failed registered service without spawning", async () => {
expect(process.exitCode).toBe(null)
})
test("evicts an unresponsive registered service before starting its replacement", async () => {
test("never evicts an unresponsive registered service automatically", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "hanging")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const endpoint = await run(
const controller = new AbortController()
const result = Effect.runPromise(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "10"],
}),
command: [process.execPath, fixture, registration, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
)
const replacement = await Bun.file(registration).json()
await waitForLines(registration + ".requests", 3)
controller.abort()
await result.catch(() => undefined)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
})
test("explicit stop refuses to signal an unidentified unresponsive PID", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "hanging")
await waitForFile(registration)
await expect(run(Service.stop({ file: registration }))).rejects.toThrow("stop its process manually")
expect(existing.exitCode).toBe(null)
})
test("requests graceful stop of the exact service instance", async () => {
@@ -161,36 +172,124 @@ test("does not spawn contenders while an incompatible service rejects replacemen
const contender = join(directory, "contender.json")
const existing = spawn(registration, "reject-stop")
await waitForFile(registration)
const controller = new AbortController()
const starting = Effect.runPromise(
const starting = run(
ensure({
file: registration,
version: "test",
canReplace: () => true,
command: [process.execPath, fixture, contender, "record-start"],
}).pipe(Effect.provide(NodeFileSystem.layer)),
{ signal: controller.signal },
}),
)
await waitForLines(registration + ".stop-attempts", 2)
controller.abort()
await starting.catch(() => undefined)
await expect(starting).rejects.toThrow("Background service rejected the stop request")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect((await Bun.file(registration + ".stop-attempts").text()).trim().split("\n")).toHaveLength(1)
expect(existing.exitCode).toBe(null)
})
test("a legacy health response is still replaced", async () => {
test("does not signal a modern service when its stop request times out", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "stop-hanging")
await waitForFile(registration)
const starting = run(
ensure({
file: registration,
version: "test",
canReplace: () => true,
command: [process.execPath, fixture, contender, "record-start"],
}),
)
await expect(starting).rejects.toThrow("Background service rejected the stop request")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect((await Bun.file(registration + ".stop-attempts").text()).trim().split("\n")).toHaveLength(1)
expect(existing.exitCode).toBe(null)
})
test("explicit stop refuses to signal when a modern stop request times out", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "stop-hanging")
await waitForFile(registration)
await expect(run(Service.stop({ file: registration }))).rejects.toThrow(
"Background service rejected the stop request",
)
expect(existing.exitCode).toBe(null)
})
test("a stale client refuses to replace a newer service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const contender = join(directory, "contender.json")
const existing = spawn(registration, "graceful")
await waitForFile(registration)
const info = await Bun.file(registration).json()
await expect(
run(
ensure({
file: registration,
version: "old",
command: [process.execPath, fixture, contender, "record-start"],
}),
),
).rejects.toThrow("Background service test does not match client old")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(info)
})
test("explicit restart can activate an installed downgrade", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const current = spawn(registration, "graceful")
await waitForFile(registration)
const before = await Bun.file(registration).json()
const options = {
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, registration, "old"],
}
await expect(run(ensure(options))).rejects.toThrow("Background service test does not match client old")
expect(current.exitCode).toBe(null)
await run(Service.stop({ file: registration }))
const endpoint = await run(ensure(options))
const after = await Bun.file(registration).json()
try {
expect(after.pid).not.toBe(before.pid)
expect(after.version).toBe("old")
expect(endpoint.url).toBe(after.url)
} finally {
process.kill(after.pid, "SIGTERM")
await waitForExit(after.pid)
}
})
test("refuses to signal a legacy service without authenticated stop", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "legacy")
await waitForFile(registration)
const starts: EnsureReason[] = []
const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
const result = run(
ensure({ file: registration, command: [], canReplace: () => true, onStart: (reason) => starts.push(reason) }),
)
await expect(result).rejects.toThrow("Missing service command")
expect(starts).toEqual(["version-mismatch"])
await existing.exited
await expect(result).rejects.toThrow("does not support authenticated stop requests")
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
})
test("waits for a slow winner while bounding lock probes", async () => {
@@ -299,6 +398,7 @@ test("replaces an incompatible owner that appears during startup", async () => {
ensure({
file: registration,
version: "test",
canReplace: () => true,
command: [process.execPath, fixture, registration, "delayed", "500"],
}),
)
@@ -317,6 +417,38 @@ test("replaces an incompatible owner that appears during startup", async () => {
}
})
test("concurrent current-version launchers converge on one replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const old = spawn(registration, "old")
await waitForFile(registration)
const endpoints = await Promise.all(
Array.from({ length: 20 }, (_, index) => {
const options = {
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
canReplace: (version: string | undefined) => version === "old",
}
return index % 2 === 0
? run(ensure(options))
: import("../src/promise/service").then((mod) => mod.Service.ensure(options))
}),
)
const info = await Bun.file(registration).json()
try {
expect(new Set(endpoints.map((endpoint) => endpoint.url))).toEqual(new Set([info.url]))
expect(info.version).toBe("test")
expect(old.exitCode).not.toBe(null)
expect(await health(info.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
function run<A, E>(effect: Effect.Effect<A, E>) {
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
}
@@ -30,6 +30,7 @@ export async function startBackgroundCli(logger: Logger) {
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
canReplace: (serverVersion) => Service.canReplaceVersion(serverVersion, version),
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
@@ -1,3 +1,4 @@
import { ClientError } from "@opencode-ai/client"
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
@@ -16,9 +17,23 @@ export function MigrationOverlay() {
onMount(async () => {
await Bun.sleep(1_000)
if (abort.signal.aborted) return
void (async () => {
while (true) {
const status = await client.api.migration.v1.status({ signal: abort.signal })
const result = await client.api.migration.v1.status({ signal: abort.signal }).then(
(status) => ({ status }),
(error: unknown) => ({ error }),
)
if ("error" in result) {
if (result.error instanceof ClientError && result.error.reason === "Transport") {
if (abort.signal.aborted) return
await Bun.sleep(1_000)
if (abort.signal.aborted) return
continue
}
throw result.error
}
const status = result.status
setProgress(status.status === "running" ? status.progress : undefined)
if (status.status === "completed") return
if (status.status === "error") throw new Error(status.error)