@@ -4,6 +4,7 @@ import { spawn, type ChildProcess } from "node:child_process"
44import { homedir } from "node:os"
55import { join } from "node:path"
66import type { DiscoverOptions , Endpoint , EnsureOptions , StopOptions } from "../service.js"
7+ import { defaultEnsureTiming , ensureTiming , type EnsureTiming } from "../service-timing.js"
78
89export * from "../service.js"
910/** Contents of the local service registration file. */
@@ -52,11 +53,12 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
5253// becomes discoverable. A contender is never killed merely for slow startup.
5354/** Ensure a healthy, compatible local service is running. */
5455export const ensure = Effect . fn ( "service.ensure" ) ( function * ( options : EnsureOptions = { } ) {
56+ const timing = ensureTiming ( options )
5557 const contenders = new Set < Contender > ( )
5658 let timeouts : { readonly info : Info ; readonly count : number } | undefined
5759 let announced = false
5860 let lastSpawn = 0
59- let spawnDelay = 5_000
61+ let spawnDelay = timing . spawnDelay
6062 const announce = ( reason : "missing" | "version-mismatch" , previousVersion ?: string ) =>
6163 Effect . sync ( ( ) => {
6264 if ( announced ) return
@@ -80,7 +82,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
8082 } )
8183 } )
8284 const found = yield * Effect . gen ( function * ( ) {
83- const registration = yield * registered ( options . file , true )
85+ const registration = yield * registered ( options . file , true , timing . requestTimeout )
8486 const info = registration . info
8587 const service = registration . service
8688 if ( registration . timedOut && info !== undefined ) {
@@ -90,28 +92,28 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
9092 }
9193 if ( timeouts . count >= 3 ) {
9294 yield * announce ( "missing" )
93- yield * evict ( info , options )
95+ yield * evict ( info , options , timing )
9496 timeouts = undefined
9597 lastSpawn = Date . now ( ) - spawnDelay
9698 }
9799 } else timeouts = undefined
98100 if ( service !== undefined ) {
99- spawnDelay = 5_000
101+ spawnDelay = timing . spawnDelay
100102 const compatible = ! service . legacy && ( options . version === undefined || service . version === options . version )
101103 if ( compatible && service . state === "ready" ) return Option . some ( service )
102104 if ( compatible && service . state === "failed" )
103105 return yield * Effect . fail ( new Error ( "Background service failed to start" ) )
104106 if ( compatible ) return Option . none < LocalService > ( )
105107 yield * announce ( "version-mismatch" , service . version )
106- yield * kill ( service , options ) . pipe ( Effect . ignore )
108+ yield * kill ( service , options , timing ) . pipe ( Effect . ignore )
107109 lastSpawn = 0
108110 return Option . none < LocalService > ( )
109111 } else if ( lastSpawn === 0 && info !== undefined ) lastSpawn = Date . now ( )
110112
111113 const finished = [ ...contenders ] . filter ( contenderFinished )
112114 const failure = finished . map ( contenderFailure ) . find ( ( error ) : error is Error => error !== undefined )
113115 if ( finished . some ( ( item ) => item . child . exitCode === 0 ) ) {
114- spawnDelay = Math . min ( spawnDelay * 2 , 30_000 )
116+ spawnDelay = Math . min ( spawnDelay * 2 , timing . maxSpawnDelay )
115117 }
116118 finished . forEach ( ( item ) => contenders . delete ( item ) )
117119 if ( failure !== undefined && contenders . size === 0 ) return yield * Effect . fail ( failure )
@@ -125,7 +127,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
125127 } ) . pipe (
126128 Effect . repeat ( {
127129 until : Option . isSome ,
128- schedule : Schedule . max ( [ Schedule . spaced ( "1 second" ) , Schedule . recurs ( 120 ) ] ) ,
130+ schedule : Schedule . max ( [ Schedule . spaced ( timing . pollInterval ) , Schedule . recurs ( timing . attempts ) ] ) ,
129131 } ) ,
130132 )
131133 if ( Option . isNone ( found ) )
@@ -150,7 +152,7 @@ function contenderFinished(contender: Contender) {
150152/** Stop the registered local service. */
151153export const stop = Effect . fn ( "service.stop" ) ( function * ( options : StopOptions = { } ) {
152154 const existing = yield * find ( options )
153- if ( existing !== undefined ) yield * kill ( existing , options )
155+ if ( existing !== undefined ) yield * kill ( existing , options , defaultEnsureTiming )
154156} )
155157
156158function fallback ( ) {
@@ -198,15 +200,19 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
198200 return ( yield * probeResult ( info , allowLegacy ) ) . service
199201} )
200202
201- const probeResult = Effect . fnUntraced ( function * ( info : Info , allowLegacy = false ) {
203+ const probeResult = Effect . fnUntraced ( function * (
204+ info : Info ,
205+ allowLegacy = false ,
206+ timeout = defaultEnsureTiming . requestTimeout ,
207+ ) {
202208 const endpoint = {
203209 url : info . url ,
204210 auth :
205211 info . password === undefined
206212 ? undefined
207213 : { type : "basic" as const , username : "opencode" , password : info . password } ,
208214 } satisfies Endpoint
209- const signal = AbortSignal . timeout ( 2_000 )
215+ const signal = AbortSignal . timeout ( timeout )
210216 const result = yield * Effect . promise ( ( ) =>
211217 fetch ( new URL ( "/api/health" , info . url ) , {
212218 headers : headers ( endpoint ) ,
@@ -249,10 +255,10 @@ const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false
249255 }
250256} )
251257
252- const registered = Effect . fnUntraced ( function * ( file ?: string , allowLegacy = false ) {
258+ const registered = Effect . fnUntraced ( function * ( file ?: string , allowLegacy = false , timeout ?: number ) {
253259 const info = yield * read ( file )
254260 if ( info === undefined ) return { info : undefined , service : undefined , timedOut : false }
255- return { info, ...( yield * probeResult ( info , allowLegacy ) ) }
261+ return { info, ...( yield * probeResult ( info , allowLegacy , timeout ) ) }
256262} )
257263
258264// Health-checked lookup without the version gate: lifecycle operations must be
@@ -263,7 +269,8 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
263269
264270// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
265271// discovery window.
266- const poll = Schedule . max ( [ Schedule . spaced ( "50 millis" ) , Schedule . recurs ( 100 ) ] )
272+ const poll = ( timing : EnsureTiming ) =>
273+ Schedule . max ( [ Schedule . spaced ( timing . stopPollInterval ) , Schedule . recurs ( timing . stopPollAttempts ) ] )
267274
268275const signal = ( pid : number , name : NodeJS . Signals ) =>
269276 Effect . try ( { try : ( ) => process . kill ( pid , name ) , catch : ( cause ) => cause } ) . pipe ( Effect . ignore )
@@ -280,21 +287,25 @@ function same(left: Info, right: Info) {
280287 return left . id === right . id && left . version === right . version && left . url === right . url && left . pid === right . pid
281288}
282289
283- const evict = Effect . fnUntraced ( function * ( info : Info , options : { readonly file ?: string } ) {
290+ const evict = Effect . fnUntraced ( function * ( info : Info , options : { readonly file ?: string } , timing : EnsureTiming ) {
284291 const current = yield * read ( options . file )
285292 if ( current === undefined || ! same ( current , info ) ) return
286293 yield * signal ( info . pid , "SIGTERM" )
287- const done = yield * stopped ( info . pid ) . pipe ( Effect . retry ( poll ) , Effect . option )
294+ const done = yield * stopped ( info . pid ) . pipe ( Effect . retry ( poll ( timing ) ) , Effect . option )
288295 if ( Option . isSome ( done ) ) return
289296
290297 const latest = yield * read ( options . file )
291298 if ( latest === undefined || ! same ( latest , info ) ) return
292299 yield * signal ( info . pid , "SIGKILL" )
293- yield * stopped ( info . pid ) . pipe ( Effect . retry ( poll ) )
300+ yield * stopped ( info . pid ) . pipe ( Effect . retry ( poll ( timing ) ) )
294301} )
295302
296- const kill = Effect . fnUntraced ( function * ( service : LocalService , options : { readonly file ?: string } ) {
297- const requested = yield * requestStop ( service )
303+ const kill = Effect . fnUntraced ( function * (
304+ service : LocalService ,
305+ options : { readonly file ?: string } ,
306+ timing : EnsureTiming ,
307+ ) {
308+ const requested = yield * requestStop ( service , timing . requestTimeout )
298309 if ( requested === "rejected" ) return
299310 if ( requested === "unsupported" ) {
300311 // A stale registration may point at a reused PID. Authenticate again
@@ -303,25 +314,25 @@ const kill = Effect.fnUntraced(function* (service: LocalService, options: { read
303314 if ( current === undefined || ! same ( current . info , service . info ) ) return
304315 yield * signal ( service . info . pid , "SIGTERM" )
305316 }
306- const done = yield * stopped ( service . info . pid ) . pipe ( Effect . retry ( poll ) , Effect . option )
317+ const done = yield * stopped ( service . info . pid ) . pipe ( Effect . retry ( poll ( timing ) ) , Effect . option )
307318 if ( Option . isSome ( done ) ) return
308319
309320 const latest = yield * find ( options )
310321 if ( latest === undefined || ! same ( latest . info , service . info ) ) return
311322 yield * signal ( service . info . pid , "SIGKILL" )
312- yield * stopped ( service . info . pid ) . pipe ( Effect . retry ( poll ) )
323+ yield * stopped ( service . info . pid ) . pipe ( Effect . retry ( poll ( timing ) ) )
313324} )
314325
315326const decodeStopResponse = Schema . decodeUnknownOption ( ServiceStatus . StopResponse )
316327
317- const requestStop = Effect . fnUntraced ( function * ( service : LocalService ) {
328+ const requestStop = Effect . fnUntraced ( function * ( service : LocalService , timeout = defaultEnsureTiming . requestTimeout ) {
318329 if ( service . info . id === undefined || service . legacy ) return "unsupported" as const
319330 const response = yield * Effect . tryPromise ( ( ) =>
320331 fetch ( new URL ( "/api/service/stop" , service . info . url ) , {
321332 method : "POST" ,
322333 headers : { ...headers ( service . endpoint ) , "content-type" : "application/json" } ,
323334 body : JSON . stringify ( { instanceID : service . info . id } ) ,
324- signal : AbortSignal . timeout ( 2_000 ) ,
335+ signal : AbortSignal . timeout ( timeout ) ,
325336 } ) ,
326337 ) . pipe ( Effect . option , Effect . map ( Option . getOrUndefined ) )
327338 if ( response === undefined || response . status === 404 || response . status === 405 ) return "unsupported" as const
0 commit comments