Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton f0308ec44a fix(cli): prevent stale service replacement 2026-08-04 16:25:14 -04:00
Kit Langton 1fd817ffcf fix(client): wait for live service contenders (#40515) 2026-08-04 20:23:57 +00:00
12 changed files with 180 additions and 271 deletions
@@ -5,6 +5,7 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import semver from "semver"
import { selfCommand } from "../util/process"
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
@@ -104,10 +105,21 @@ export const options = Effect.fnUntraced(function* () {
return {
file,
version: OPENCODE_VERSION,
canReplace: (version: string | undefined) => canReplaceVersion(version),
command: [...selfCommand(), "serve", "--service"],
}
})
export function canReplaceVersion(serverVersion: string | undefined, clientVersion = OPENCODE_VERSION) {
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, legacyConfigFile } = yield* paths
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
+9
View File
@@ -47,6 +47,15 @@ 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(ServiceConfig.canReplaceVersion("1.2.3", "1.2.4")).toBe(true)
expect(ServiceConfig.canReplaceVersion("1.2.4", "1.2.3")).toBe(false)
expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.3")).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(undefined, "1.2.3")).toBe(true)
})
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")!)
+11 -6
View File
@@ -3,7 +3,13 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import {
VersionMismatchError,
type DiscoverOptions,
type Endpoint,
type EnsureOptions,
type StopOptions,
} from "../service.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -56,7 +62,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -84,27 +89,27 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const info = registration.info
const service = registration.service
if (service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service)
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) === false)
return yield* Effect.fail(new VersionMismatchError(options.version, service.version))
yield* announce("version-mismatch", service.version)
yield* kill(service, options).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined)
if (failure !== undefined) return yield* Effect.fail(failure)
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing")
+12 -6
View File
@@ -2,7 +2,14 @@ import { readFile } from "node:fs/promises"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import {
VersionMismatchError,
type DiscoverOptions,
type Endpoint,
type Info,
type EnsureOptions,
type StopOptions,
} from "../service.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -37,7 +44,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -65,27 +71,27 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
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) === false)
throw new VersionMismatchError(options.version, service.version)
announce("version-mismatch", service.version)
await kill(service, options).catch(() => undefined)
lastSpawn = 0
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) throw failure
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
+14
View File
@@ -28,10 +28,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 true. */
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(`Client version ${clientVersion ?? "unknown"} cannot replace server version ${serverVersion ?? "unknown"}`)
}
}
/** Options used to stop the local OpenCode service. */
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
+9 -3
View File
@@ -9,14 +9,20 @@ if (mode === "record-start") {
}
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
if (
mode === "delayed" ||
mode === "delayed-failed" ||
mode === "coordinated" ||
mode === "coordinated-failed-loser"
) {
await appendFile(registration + ".starts", process.pid + "\n")
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
.then(() => true)
.catch(() => false)
if (!owner) process.exit()
if (mode === "coordinated") {
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0)
if (mode === "coordinated" || mode === "coordinated-failed-loser") {
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
} else await Bun.sleep(Number(delay))
if (mode === "delayed-failed") process.exit(1)
}
@@ -44,6 +44,24 @@ test("ensures a missing service with native promises", async () => {
}
}, 15_000)
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
test("reports a failed registered service", async () => {
const registration = await setup("failed-owner")
@@ -52,6 +70,25 @@ test("reports a failed registered service", async () => {
)
})
test("does not replace a version rejected by the caller", 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(
Service.ensure({
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, contender, "record-start"],
}),
).rejects.toThrow("Client version old cannot replace server version test")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(process.kill(info.pid, 0)).toBe(true)
})
test("requests graceful stop of the exact service instance", async () => {
const registration = await setup("graceful")
const info = await Bun.file(registration).json()
+40
View File
@@ -107,6 +107,28 @@ test("does not spawn contenders while an incompatible service rejects replacemen
expect(existing.exitCode).toBe(null)
})
test("does not replace a version rejected by the caller", 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)
await expect(
run(
Service.ensure({
file: registration,
version: "old",
canReplace: () => false,
command: [process.execPath, fixture, contender, "record-start"],
}),
),
).rejects.toThrow("Client version old cannot replace server version test")
expect(await Bun.file(contender + ".started").exists()).toBe(false)
expect(existing.exitCode).toBe(null)
})
test("a legacy health response is still replaced", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -141,6 +163,24 @@ test("waits for a slow winner while bounding lock probes", async () => {
}
}, 15_000)
test("waits for a live contender when another contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
} finally {
process.kill(info.pid, "SIGTERM")
}
}, 15_000)
test("reports a contender that fails to start", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+36 -69
View File
@@ -53,7 +53,6 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import { createPromptSubmission } from "../../prompt/submission"
export type PromptProps = {
sessionID?: string
@@ -253,7 +252,6 @@ export function Prompt(props: PromptProps) {
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
const promptSubmission = createPromptSubmission()
const hasRightContent = createMemo(() => Boolean(props.right))
function promptModelWarning() {
@@ -954,72 +952,27 @@ export function Prompt(props: PromptProps) {
}
const variant = local.model.variant.current()
const currentMode = store.mode
const prompt = {
text: store.prompt.text,
files: store.prompt.files?.map((file) => ({
...file,
mention: file.mention && { ...file.mention },
})),
agents: store.prompt.agents?.map((agent) => ({
...agent,
mention: agent.mention && { ...agent.mention },
})),
pasted: store.prompt.pasted.map((part) => ({
...part,
source: { ...part.source },
})),
} satisfies PromptInfo
const inputText = expandTrackedPastedText(
prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const directory = props.sessionID == null ? await move.getDirectory() : undefined
if (props.sessionID == null && move.pending() && !directory) return false
const sessionInput = {
location: directory ? { directory } : (currentLocation.ref ?? data.location.default()),
agent: agent.id,
model: {
providerID: selectedModel.providerID,
id: selectedModel.modelID,
variant,
},
}
const promptInput = {
text: inputText,
files: prompt.files,
agents: prompt.agents,
}
// Keep both IDs stable until the whole create/admit sequence succeeds. If the
// transport drops after either durable write, retry reconciles that write.
const sessionID = await promptSubmission.begin(
Bun.hash(
JSON.stringify({
sessionID: props.sessionID,
session: sessionInput,
prompt: promptInput,
mode: currentMode,
}),
),
props.sessionID,
)
let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false
if (props.sessionID == null) {
if (sessionID == null) {
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
// The location context is where the next session is created: seeded by the home
// route (launch cwd, inherited session location, or picked project) and updated
// by /cd before a session exists.
const location = currentLocation.ref ?? data.location.default()
const created = await client.api.session
.create({
id: sessionID,
...sessionInput,
location: directory ? { directory } : location,
agent: agent.id,
model: {
providerID: selectedModel.providerID,
id: selectedModel.modelID,
variant,
},
})
.catch(() => undefined)
@@ -1033,13 +986,27 @@ export function Prompt(props: PromptProps) {
return true
}
sessionID = created.id
session = created
}
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (currentMode === "shell") {
if (store.mode === "shell") {
move.startSubmit()
void client.api.session.shell({
sessionID,
@@ -1066,9 +1033,9 @@ export function Prompt(props: PromptProps) {
command: command.slice(1),
arguments: args,
agent: agent.id,
model: sessionInput.model,
files: promptInput.files,
agents: promptInput.agents,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
files: store.prompt.files,
agents: store.prompt.agents,
})
.catch((error) => {
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
@@ -1100,7 +1067,7 @@ export function Prompt(props: PromptProps) {
) {
await client.api.session.switchModel({
sessionID,
model: sessionInput.model,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
})
}
if (session?.revert) {
@@ -1133,8 +1100,9 @@ export function Prompt(props: PromptProps) {
const error = await client.api.session
.prompt({
sessionID,
id: await promptSubmission.message(),
...promptInput,
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
})
.then(
() => undefined,
@@ -1146,9 +1114,8 @@ export function Prompt(props: PromptProps) {
}
if (pendingEditorSelection) editor.markSelectionSent()
}
promptSubmission.complete()
history.append({
...prompt,
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
-35
View File
@@ -1,35 +0,0 @@
type PromptSubmission = {
key: number | bigint
sessionID: string
messageID?: string
}
export function createPromptSubmission() {
let pending: PromptSubmission | undefined
return {
async begin(key: number | bigint, sessionID?: string) {
if (pending?.key === key && (sessionID === undefined || pending.sessionID === sessionID)) return pending.sessionID
if (sessionID !== undefined) {
pending = { key, sessionID }
return pending.sessionID
}
const { SessionID } = await import("@opencode-ai/schema/session-id")
pending = {
key,
sessionID: SessionID.create(),
}
return pending.sessionID
},
async message() {
if (!pending) throw new Error("Prompt submission has not started")
if (pending.messageID) return pending.messageID
const { SessionMessage } = await import("@opencode-ai/schema/session-message")
pending.messageID = SessionMessage.ID.create()
return pending.messageID
},
complete() {
pending = undefined
},
}
}
-120
View File
@@ -302,123 +302,3 @@ test("session startup prompt is submitted exactly once", async () => {
mock.restore()
}
})
test("home prompt retry reuses the accepted session and message IDs", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const ready = Promise.withResolvers<void>()
const sessionReady = Promise.withResolvers<void>()
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") ready.resolve()
if (title === "OC | New session") sessionReady.resolve()
setTitle(title)
}
const events = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const creates: unknown[] = []
const prompts: unknown[] = []
const modelLoaded = Promise.withResolvers<void>()
const firstPrompt = Promise.withResolvers<void>()
const secondPrompt = Promise.withResolvers<void>()
let createdID: string | undefined
const session = (id: string) => ({
id,
title: "New session",
projectID: "project",
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
})
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({
location,
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
})
if (url.pathname === "/api/model") {
modelLoaded.resolve()
return json({
location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
})
}
if (url.pathname === "/api/session" && request.method === "GET")
return json({ data: createdID ? [session(createdID)] : [], cursor: {} })
if (url.pathname === "/api/session" && request.method === "POST") {
const body = await request.json()
if (!body || typeof body !== "object" || !("id" in body) || typeof body.id !== "string") {
throw new Error("session create did not supply an ID")
}
creates.push(body)
createdID = body.id
return json({ data: session(body.id) })
}
if (createdID && url.pathname === `/api/session/${createdID}`) return json({ data: session(createdID) })
if (createdID && url.pathname === `/api/session/${createdID}/message`) return json({ data: [], cursor: {} })
if (createdID && url.pathname === `/api/session/${createdID}/pending`) return json({ data: [] })
if (createdID && url.pathname === `/api/session/${createdID}/permission`) return json({ data: [] })
if (createdID && url.pathname === `/api/session/${createdID}/prompt`) {
prompts.push(await request.json())
if (prompts.length === 1) {
firstPrompt.resolve()
return json({ error: "response lost after admission" }, { status: 500 })
}
secondPrompt.resolve()
return json({ data: {} })
}
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await Promise.all([ready.promise, modelLoaded.promise])
await setup.mockInput.typeText("RETRY_READY")
setup.mockInput.pressEnter()
await Promise.race([
firstPrompt.promise,
Bun.sleep(2_000).then(() => {
throw new Error("first home prompt was not submitted")
}),
])
await setup.waitForFrame((frame) => frame.includes("Failed to send prompt"))
setup.mockInput.pressEnter()
await Promise.race([
secondPrompt.promise,
Bun.sleep(2_000).then(() => {
throw new Error("home prompt was not retried")
}),
])
await sessionReady.promise
setup.renderer.destroy()
await task
expect(creates).toHaveLength(2)
expect(prompts).toHaveLength(2)
expect(creates[1]).toEqual(creates[0])
expect(prompts[1]).toEqual(prompts[0])
expect(creates[0]).toMatchObject({ id: expect.stringMatching(/^ses_/) })
expect(prompts[0]).toMatchObject({ id: expect.stringMatching(/^msg_/), text: "RETRY_READY" })
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
@@ -1,32 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Session } from "@opencode-ai/schema/session"
import { createPromptSubmission } from "../../src/prompt/submission"
describe("prompt submission identity", () => {
test("reuses identities while retrying the same submission", async () => {
const submission = createPromptSubmission()
const firstSession = await submission.begin(1n)
const firstMessage = await submission.message()
expect(await submission.begin(1n)).toBe(firstSession)
expect(await submission.message()).toBe(firstMessage)
expect(await submission.begin(2n)).not.toBe(firstSession)
expect(await submission.message()).not.toBe(firstMessage)
})
test("preserves an existing session while retrying its prompt", async () => {
const submission = createPromptSubmission()
const sessionID = Session.ID.create()
expect(await submission.begin(1n, sessionID)).toBe(sessionID)
expect(await submission.begin(1n, sessionID)).toBe(sessionID)
})
test("starts a new identity after completion", async () => {
const submission = createPromptSubmission()
const first = await submission.begin(1n)
submission.complete()
expect(await submission.begin(1n)).not.toBe(first)
})
})