Просмотр исходного кода

fix(cli): harden daemon lifecycle (#30844)

Dax 2 месяцев назад
Родитель
Сommit
b01eb22b2c
1 измененных файлов с 78 добавлено и 38 удалено
  1. 78 38
      packages/cli/src/services/daemon.ts

+ 78 - 38
packages/cli/src/services/daemon.ts

@@ -1,9 +1,10 @@
 import { Global } from "@opencode-ai/core/global"
 import { Global } from "@opencode-ai/core/global"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { ServerAuth } from "@opencode-ai/server/auth"
 import { ServerAuth } from "@opencode-ai/server/auth"
 import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
 import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
 import { HttpServer } from "effect/unstable/http"
 import { HttpServer } from "effect/unstable/http"
-import { randomBytes } from "crypto"
+import { randomBytes, randomUUID } from "crypto"
 import path from "path"
 import path from "path"
 
 
 export interface Interface {
 export interface Interface {
@@ -17,6 +18,18 @@ export interface Interface {
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
 
 
+const Registration = Schema.Struct({
+  id: Schema.optional(Schema.String),
+  version: Schema.optional(Schema.String),
+  url: Schema.String,
+  pid: Schema.Int.check(Schema.isGreaterThan(0)),
+})
+type Registration = typeof Registration.Type
+
+function sameRegistration(left: Registration, right: Registration) {
+  return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
+}
+
 export const layer = Layer.effect(
 export const layer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
@@ -24,9 +37,7 @@ export const layer = Layer.effect(
     const directory = Global.Path.state
     const directory = Global.Path.state
     const file = path.join(directory, "server.json")
     const file = path.join(directory, "server.json")
     const passwordFile = path.join(directory, "password")
     const passwordFile = path.join(directory, "password")
-    const decodeRegistration = Schema.decodeUnknownEffect(
-      Schema.fromJsonString(Schema.Struct({ url: Schema.String, pid: Schema.Number })),
-    )
+    const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
 
 
     const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
     const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
       const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
       const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -53,15 +64,52 @@ export const layer = Layer.effect(
     const healthy = Effect.fnUntraced(function* () {
     const healthy = Effect.fnUntraced(function* () {
       const info = yield* registration()
       const info = yield* registration()
       const client = yield* createClient(info.url)
       const client = yield* createClient(info.url)
-      const response = yield* Effect.tryPromise(() => client.v2.health.get())
+      const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) }))
       if (response.data?.healthy === true) return info
       if (response.data?.healthy === true) return info
       return yield* Effect.fail(new Error("Registered server is not healthy"))
       return yield* Effect.fail(new Error("Registered server is not healthy"))
     })
     })
 
 
+    const compatible = Effect.fnUntraced(function* () {
+      const info = yield* healthy()
+      if (info.version === InstallationVersion) return info
+      return yield* Effect.fail(new Error("Registered server version does not match the client"))
+    })
+
+    const signal = (pid: number, signal: NodeJS.Signals) =>
+      Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
+
+    const awaitStopped = Effect.fnUntraced(function* (pid: number) {
+      const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
+        Effect.orElseSucceed(() => false),
+      )
+      if (!running) return true
+      return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
+    })
+
+    const stopProcess = Effect.fnUntraced(function* (info: Registration) {
+      const current = yield* healthy().pipe(Effect.option)
+      if (Option.isNone(current) || !sameRegistration(current.value, info)) return
+
+      yield* signal(info.pid, "SIGTERM")
+      const stopped = yield* awaitStopped(info.pid).pipe(
+        Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
+        Effect.option,
+      )
+      if (Option.isSome(stopped)) return
+
+      const latest = yield* healthy().pipe(Effect.option)
+      if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return
+      yield* signal(info.pid, "SIGKILL")
+      yield* awaitStopped(info.pid).pipe(
+        Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
+      )
+    })
+
     const start = Effect.fn("cli.daemon.start")(function* () {
     const start = Effect.fn("cli.daemon.start")(function* () {
       const existing = yield* healthy().pipe(Effect.option)
       const existing = yield* healthy().pipe(Effect.option)
       const found = Option.getOrUndefined(existing)
       const found = Option.getOrUndefined(existing)
-      if (found) return found.url
+      if (found?.version === InstallationVersion) return found.url
+      if (found) yield* stopProcess(found).pipe(Effect.ignore)
 
 
       yield* Effect.sync(() => {
       yield* Effect.sync(() => {
         const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
         const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
@@ -72,7 +120,7 @@ export const layer = Layer.effect(
         }).unref()
         }).unref()
       })
       })
 
 
-      return yield* healthy().pipe(
+      return yield* compatible().pipe(
         Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
         Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
         Effect.map((info) => info.url),
         Effect.map((info) => info.url),
         Effect.mapError(() => new Error("Failed to start server")),
         Effect.mapError(() => new Error("Failed to start server")),
@@ -86,52 +134,44 @@ export const layer = Layer.effect(
     const status = Effect.fn("cli.daemon.status")(function* () {
     const status = Effect.fn("cli.daemon.status")(function* () {
       const existing = yield* healthy().pipe(Effect.option)
       const existing = yield* healthy().pipe(Effect.option)
       const found = Option.getOrUndefined(existing)
       const found = Option.getOrUndefined(existing)
-      if (found) return found.url
+      if (found?.version === InstallationVersion) return found.url
+      if (found) return undefined
       yield* fs.remove(file).pipe(Effect.ignore)
       yield* fs.remove(file).pipe(Effect.ignore)
       return undefined
       return undefined
     })
     })
 
 
-    const signal = (pid: number, signal: NodeJS.Signals) =>
-      Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
-
-    const awaitStopped = Effect.fnUntraced(function* (pid: number) {
-      const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
-        Effect.orElseSucceed(() => false),
-      )
-      if (!running) return true
-      return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
-    })
-
     const stop = Effect.fn("cli.daemon.stop")(function* () {
     const stop = Effect.fn("cli.daemon.stop")(function* () {
       const existing = yield* healthy().pipe(Effect.option)
       const existing = yield* healthy().pipe(Effect.option)
       // A stale registration may point at a PID that has since been reused by
       // A stale registration may point at a PID that has since been reused by
       // another process. Only signal the PID after authenticating the server.
       // another process. Only signal the PID after authenticating the server.
       if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
       if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
-      const pid = existing.value.pid
-      yield* signal(pid, "SIGTERM")
-      const stopped = yield* awaitStopped(pid).pipe(
-        Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
-        Effect.option,
-      )
-      if (Option.isNone(stopped)) {
-        yield* signal(pid, "SIGKILL")
-        yield* awaitStopped(pid).pipe(
-          Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
-        )
-      }
+      yield* stopProcess(existing.value)
       yield* fs.remove(file).pipe(Effect.ignore)
       yield* fs.remove(file).pipe(Effect.ignore)
     })
     })
 
 
     const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
     const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
-      const temp = file + ".tmp"
+      const id = randomUUID()
+      const temp = file + "." + id + ".tmp"
       yield* fs.makeDirectory(directory, { recursive: true })
       yield* fs.makeDirectory(directory, { recursive: true })
-      yield* fs.writeFileString(temp, JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }), {
-        mode: 0o600,
-      })
+      yield* fs.writeFileString(
+        temp,
+        JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }),
+        { mode: 0o600 },
+      )
       yield* fs.rename(temp, file)
       yield* fs.rename(temp, file)
-      // The metadata file represents this live listener, not persistent config.
-      // Scope shutdown removes it when the server exits normally.
-      yield* Effect.addFinalizer(() => fs.remove(file).pipe(Effect.ignore))
+      yield* registration()
+        .pipe(
+          Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))),
+          Effect.catch(() => signal(process.pid, "SIGTERM")),
+          Effect.repeat(Schedule.spaced("10 seconds")),
+          Effect.forkScoped,
+        )
+      yield* Effect.addFinalizer(() =>
+        registration().pipe(
+          Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)),
+          Effect.ignore,
+        ),
+      )
     })
     })
 
 
     return Service.of({ client, start, status, stop, password, register })
     return Service.of({ client, start, status, stop, password, register })