11import { Global } from "@opencode-ai/core/global"
2+ import { InstallationVersion } from "@opencode-ai/core/installation/version"
23import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
34import { ServerAuth } from "@opencode-ai/server/auth"
45import { Context , Effect , FileSystem , Layer , Option , Schedule , Schema , Scope } from "effect"
56import { HttpServer } from "effect/unstable/http"
6- import { randomBytes } from "crypto"
7+ import { randomBytes , randomUUID } from "crypto"
78import path from "path"
89
910export interface Interface {
@@ -17,16 +18,26 @@ export interface Interface {
1718
1819export 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+
2033export 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 ( / \. e x e $ / , "" ) !== "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