Skip to content

Commit b01eb22

Browse files
authored
fix(cli): harden daemon lifecycle (anomalyco#30844)
1 parent 3cf1cef commit b01eb22

1 file changed

Lines changed: 78 additions & 38 deletions

File tree

packages/cli/src/services/daemon.ts

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Global } from "@opencode-ai/core/global"
2+
import { InstallationVersion } from "@opencode-ai/core/installation/version"
23
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
34
import { ServerAuth } from "@opencode-ai/server/auth"
45
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
56
import { HttpServer } from "effect/unstable/http"
6-
import { randomBytes } from "crypto"
7+
import { randomBytes, randomUUID } from "crypto"
78
import path from "path"
89

910
export interface Interface {
@@ -17,16 +18,26 @@ export interface Interface {
1718

1819
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
1920

21+
const Registration = Schema.Struct({
22+
id: Schema.optional(Schema.String),
23+
version: Schema.optional(Schema.String),
24+
url: Schema.String,
25+
pid: Schema.Int.check(Schema.isGreaterThan(0)),
26+
})
27+
type Registration = typeof Registration.Type
28+
29+
function sameRegistration(left: Registration, right: Registration) {
30+
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
31+
}
32+
2033
export const layer = Layer.effect(
2134
Service,
2235
Effect.gen(function* () {
2336
const fs = yield* FileSystem.FileSystem
2437
const directory = Global.Path.state
2538
const file = path.join(directory, "server.json")
2639
const passwordFile = path.join(directory, "password")
27-
const decodeRegistration = Schema.decodeUnknownEffect(
28-
Schema.fromJsonString(Schema.Struct({ url: Schema.String, pid: Schema.Number })),
29-
)
40+
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
3041

3142
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
3243
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -53,15 +64,52 @@ export const layer = Layer.effect(
5364
const healthy = Effect.fnUntraced(function* () {
5465
const info = yield* registration()
5566
const client = yield* createClient(info.url)
56-
const response = yield* Effect.tryPromise(() => client.v2.health.get())
67+
const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) }))
5768
if (response.data?.healthy === true) return info
5869
return yield* Effect.fail(new Error("Registered server is not healthy"))
5970
})
6071

72+
const compatible = Effect.fnUntraced(function* () {
73+
const info = yield* healthy()
74+
if (info.version === InstallationVersion) return info
75+
return yield* Effect.fail(new Error("Registered server version does not match the client"))
76+
})
77+
78+
const signal = (pid: number, signal: NodeJS.Signals) =>
79+
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
80+
81+
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
82+
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
83+
Effect.orElseSucceed(() => false),
84+
)
85+
if (!running) return true
86+
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
87+
})
88+
89+
const stopProcess = Effect.fnUntraced(function* (info: Registration) {
90+
const current = yield* healthy().pipe(Effect.option)
91+
if (Option.isNone(current) || !sameRegistration(current.value, info)) return
92+
93+
yield* signal(info.pid, "SIGTERM")
94+
const stopped = yield* awaitStopped(info.pid).pipe(
95+
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
96+
Effect.option,
97+
)
98+
if (Option.isSome(stopped)) return
99+
100+
const latest = yield* healthy().pipe(Effect.option)
101+
if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return
102+
yield* signal(info.pid, "SIGKILL")
103+
yield* awaitStopped(info.pid).pipe(
104+
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
105+
)
106+
})
107+
61108
const start = Effect.fn("cli.daemon.start")(function* () {
62109
const existing = yield* healthy().pipe(Effect.option)
63110
const found = Option.getOrUndefined(existing)
64-
if (found) return found.url
111+
if (found?.version === InstallationVersion) return found.url
112+
if (found) yield* stopProcess(found).pipe(Effect.ignore)
65113

66114
yield* Effect.sync(() => {
67115
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
@@ -72,7 +120,7 @@ export const layer = Layer.effect(
72120
}).unref()
73121
})
74122

75-
return yield* healthy().pipe(
123+
return yield* compatible().pipe(
76124
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
77125
Effect.map((info) => info.url),
78126
Effect.mapError(() => new Error("Failed to start server")),
@@ -86,52 +134,44 @@ export const layer = Layer.effect(
86134
const status = Effect.fn("cli.daemon.status")(function* () {
87135
const existing = yield* healthy().pipe(Effect.option)
88136
const found = Option.getOrUndefined(existing)
89-
if (found) return found.url
137+
if (found?.version === InstallationVersion) return found.url
138+
if (found) return undefined
90139
yield* fs.remove(file).pipe(Effect.ignore)
91140
return undefined
92141
})
93142

94-
const signal = (pid: number, signal: NodeJS.Signals) =>
95-
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
96-
97-
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
98-
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
99-
Effect.orElseSucceed(() => false),
100-
)
101-
if (!running) return true
102-
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
103-
})
104-
105143
const stop = Effect.fn("cli.daemon.stop")(function* () {
106144
const existing = yield* healthy().pipe(Effect.option)
107145
// A stale registration may point at a PID that has since been reused by
108146
// another process. Only signal the PID after authenticating the server.
109147
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
110-
const pid = existing.value.pid
111-
yield* signal(pid, "SIGTERM")
112-
const stopped = yield* awaitStopped(pid).pipe(
113-
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
114-
Effect.option,
115-
)
116-
if (Option.isNone(stopped)) {
117-
yield* signal(pid, "SIGKILL")
118-
yield* awaitStopped(pid).pipe(
119-
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
120-
)
121-
}
148+
yield* stopProcess(existing.value)
122149
yield* fs.remove(file).pipe(Effect.ignore)
123150
})
124151

125152
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
126-
const temp = file + ".tmp"
153+
const id = randomUUID()
154+
const temp = file + "." + id + ".tmp"
127155
yield* fs.makeDirectory(directory, { recursive: true })
128-
yield* fs.writeFileString(temp, JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }), {
129-
mode: 0o600,
130-
})
156+
yield* fs.writeFileString(
157+
temp,
158+
JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }),
159+
{ mode: 0o600 },
160+
)
131161
yield* fs.rename(temp, file)
132-
// The metadata file represents this live listener, not persistent config.
133-
// Scope shutdown removes it when the server exits normally.
134-
yield* Effect.addFinalizer(() => fs.remove(file).pipe(Effect.ignore))
162+
yield* registration()
163+
.pipe(
164+
Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))),
165+
Effect.catch(() => signal(process.pid, "SIGTERM")),
166+
Effect.repeat(Schedule.spaced("10 seconds")),
167+
Effect.forkScoped,
168+
)
169+
yield* Effect.addFinalizer(() =>
170+
registration().pipe(
171+
Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)),
172+
Effect.ignore,
173+
),
174+
)
135175
})
136176

137177
return Service.of({ client, start, status, stop, password, register })

0 commit comments

Comments
 (0)