Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton fb8defdc30 fix(client): prevent stale service replacement 2026-08-12 19:58:26 -04:00
12 changed files with 70 additions and 8 deletions
+4
View File
@@ -189,12 +189,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:",
},
@@ -6467,6 +6469,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=="],
+3 -1
View File
@@ -103,7 +103,9 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
version: input.checkVersion
? (version: string) => Service.isServiceVersionCompatible(version, OPENCODE_VERSION)
: undefined,
command: [...selfCommand(), "serve", "--service"],
}
})
+5 -1
View File
@@ -64,7 +64,11 @@ test("service options only require a matching version when requested", async ()
try {
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
const version = (await runPromise(ServiceConfig.options({ checkVersion: true }))).version
expect(version).toBeFunction()
if (typeof version !== "function") throw new Error("Expected a service version predicate")
expect(version(OPENCODE_VERSION)).toBe(true)
expect(version("999.0.0")).toBe(true)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
+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"
@@ -48,6 +49,7 @@
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:"
}
+2 -2
View File
@@ -10,7 +10,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { isServiceVersionCompatible, matchesVersion } from "../service-version.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -325,4 +325,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, isServiceVersionCompatible, Info }
+2 -2
View File
@@ -9,7 +9,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { isServiceVersionCompatible, matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -278,4 +278,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, isServiceVersionCompatible }
+10
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,12 @@ export function matchesVersion(version: string | undefined, options: DiscoverOpt
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
/** Whether a service is at least as new as its client. */
export function isServiceVersionCompatible(serverVersion: string, clientVersion: string) {
if (serverVersion === clientVersion) return true
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 true
return semver.gte(server, client)
}
+2
View File
@@ -1,3 +1,5 @@
export { isServiceVersionCompatible } from "./service-version.js"
/** Connection details for a local OpenCode service. */
export type Endpoint = {
/** Base URL of the service. */
+1
View File
@@ -31,6 +31,7 @@ let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
if (mode === "newer") version = "0.0.0-next-17272"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test"
import { isServiceVersionCompatible } from "../src/service"
test("accepts the same or a newer service version", () => {
expect(isServiceVersionCompatible("0.0.0-next-17272", "0.0.0-next-17272")).toBe(true)
expect(isServiceVersionCompatible("0.0.0-next-17272", "0.0.0-next-17271")).toBe(true)
expect(isServiceVersionCompatible("0.0.0-next-17271", "0.0.0-next-17272")).toBe(false)
expect(isServiceVersionCompatible("0.0.0-next-15000", "0.0.0-next-9999")).toBe(true)
})
test("accepts incomparable development versions", () => {
expect(isServiceVersionCompatible("development-a", "development-b")).toBe(true)
})
+24
View File
@@ -68,6 +68,30 @@ test("reuses a compatible registered service", async () => {
expect(existing.exitCode).toBe(null)
})
test("a stale client reuses a newer registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "newer")
await waitForFile(registration)
const original = await Bun.file(registration).json()
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => Service.isServiceVersionCompatible(version, "0.0.0-next-17271"),
command: [process.execPath, fixture, registration, "record-start"],
onStart: (reason) => starts.push(reason),
}),
)
expect(endpoint.url).toBe(original.url)
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
expect(await Bun.file(registration).json()).toEqual(original)
expect(await Bun.file(registration + ".started").exists()).toBe(false)
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+1 -1
View File
@@ -29,7 +29,7 @@ export async function startBackgroundCli(logger: Logger) {
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
version: (serverVersion) => Service.isServiceVersionCompatible(serverVersion, version),
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})