Skip to content

Commit 397993e

Browse files
authored
fix(client): preserve compatible background service (anomalyco#36583)
1 parent c0ed010 commit 397993e

4 files changed

Lines changed: 142 additions & 6 deletions

File tree

.changeset/calm-services-start.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opencode-ai/client": patch
3+
---
4+
5+
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.

packages/client/src/effect/service.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
5959
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
6060
const compatible = yield* discover(options)
6161
if (compatible !== undefined) return compatible
62-
const mismatched = yield* find(options)
63-
yield* Effect.sync(() =>
64-
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
65-
)
66-
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
62+
const existing = yield* find(options)
63+
if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version))
64+
return existing.endpoint
65+
yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info))
66+
if (existing !== undefined) yield* kill(existing.info, options).pipe(Effect.ignore)
6767

6868
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
6969
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
@@ -138,6 +138,7 @@ const read = Effect.fnUntraced(function* (file?: string) {
138138
type LocalService = {
139139
readonly info: Info
140140
readonly endpoint: Endpoint
141+
readonly version?: string
141142
}
142143

143144
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
@@ -161,7 +162,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
161162
if (health.value.pid !== info.pid) return undefined
162163
if (info.version !== undefined && health.value.version !== info.version) return undefined
163164
if (version !== undefined && health.value.version !== version) return undefined
164-
return { info, endpoint } satisfies LocalService
165+
return { info, endpoint, version: health.value.version } satisfies LocalService
165166
}
166167
if (
167168
!allowLegacy ||
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { rename, writeFile } from "node:fs/promises"
2+
3+
const [registration, mode] = process.argv.slice(2)
4+
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
5+
6+
let requests = 0
7+
const server = Bun.serve({
8+
port: 0,
9+
async fetch(request) {
10+
if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 })
11+
requests += 1
12+
if (mode === "modern" && requests === 1) {
13+
await writeFile(registration + ".first-request", "")
14+
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
15+
return new Response(null, { status: 503 })
16+
}
17+
if (mode === "legacy") return Response.json({ healthy: true })
18+
return Response.json({ healthy: true, version: "test", pid: process.pid })
19+
},
20+
})
21+
22+
await writeFile(
23+
registration + ".tmp",
24+
JSON.stringify({
25+
id: crypto.randomUUID(),
26+
version: mode === "legacy" ? undefined : "test",
27+
url: server.url.toString(),
28+
pid: process.pid,
29+
}),
30+
{ mode: 0o600 },
31+
)
32+
await rename(registration + ".tmp", registration)
33+
34+
const shutdown = () => {
35+
server.stop(true)
36+
process.exit()
37+
}
38+
process.on("SIGTERM", shutdown)
39+
process.on("SIGINT", shutdown)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { NodeFileSystem } from "@effect/platform-node"
2+
import { afterEach, expect, test } from "bun:test"
3+
import { Effect } from "effect"
4+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
5+
import { tmpdir } from "node:os"
6+
import { join } from "node:path"
7+
import { Service } from "../src/effect/index"
8+
9+
const fixture = join(import.meta.dir, "fixture/service.ts")
10+
const processes: Bun.Subprocess[] = []
11+
const directories: string[] = []
12+
13+
afterEach(async () => {
14+
processes.forEach((process) => process.kill("SIGTERM"))
15+
await Promise.all(processes.splice(0).map((process) => process.exited))
16+
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
17+
})
18+
19+
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
20+
const directory = await temp()
21+
const registration = join(directory, "service.json")
22+
spawn(registration, "modern")
23+
await waitForFile(registration)
24+
const original = await Bun.file(registration).json()
25+
26+
const starts: Service.StartReason[] = []
27+
const first = run(
28+
Service.start({
29+
file: registration,
30+
version: "test",
31+
command: [],
32+
onStart: (reason) => starts.push(reason),
33+
}),
34+
)
35+
await waitForFile(registration + ".first-request")
36+
37+
const resolved = await run(Service.start({ file: registration, version: "test" }))
38+
expect(resolved.url).toBe(original.url)
39+
40+
await writeFile(registration + ".release", "")
41+
await first
42+
43+
expect(starts).toEqual([])
44+
expect(await Bun.file(registration).json()).toEqual(original)
45+
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
46+
})
47+
48+
test("a legacy health response is still replaced", async () => {
49+
const directory = await temp()
50+
const registration = join(directory, "service.json")
51+
const existing = spawn(registration, "legacy")
52+
await waitForFile(registration)
53+
54+
const starts: Service.StartReason[] = []
55+
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
56+
57+
await expect(result).rejects.toThrow("Missing service command")
58+
expect(starts).toEqual(["version-mismatch"])
59+
await existing.exited
60+
})
61+
62+
function run<A, E>(effect: Effect.Effect<A, E, never>) {
63+
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
64+
}
65+
66+
function spawn(registration: string, mode: string, ...args: string[]) {
67+
const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
68+
stdout: "ignore",
69+
stderr: "inherit",
70+
})
71+
processes.push(subprocess)
72+
return subprocess
73+
}
74+
75+
async function temp() {
76+
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
77+
directories.push(directory)
78+
return directory
79+
}
80+
81+
async function waitForFile(file: string) {
82+
for (let attempt = 0; attempt < 600; attempt++) {
83+
if (await Bun.file(file).exists()) return
84+
await Bun.sleep(5)
85+
}
86+
throw new Error(`Timed out waiting for ${file}`)
87+
}
88+
89+
async function health(url: string) {
90+
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
91+
}

0 commit comments

Comments
 (0)