11import { readFile } from "node:fs/promises"
2- import { spawn , type ChildProcess } from "node:child_process"
32import { homedir } from "node:os"
43import { join } from "node:path"
54import type { DiscoverOptions , Endpoint , Info , EnsureOptions , StopOptions } from "../service.js"
5+ import {
6+ contenderFailure ,
7+ contenderFinished ,
8+ type ServiceContender ,
9+ spawnServiceContender ,
10+ } from "../service-contender.js"
611import { defaultEnsureTiming , ensureTiming , type EnsureTiming } from "../service-timing.js"
712import type { ServiceHealth , ServiceStopResponse } from "./generated/types.js"
813
@@ -14,11 +19,6 @@ export * from "../service.js"
1419// intentionally implemented with Node APIs so Promise clients do not need
1520// Effect or @effect /platform-node at runtime.
1621
17- type Contender = {
18- readonly child : ChildProcess
19- readonly error : ( ) => Error | undefined
20- }
21-
2222/** Discover a healthy, compatible local service without starting one. */
2323export async function discover ( options : DiscoverOptions = { } ) {
2424 return ( await discoverLocal ( options ) ) ?. endpoint
@@ -35,7 +35,7 @@ async function discoverLocal(options: DiscoverOptions) {
3535export async function ensure ( options : EnsureOptions = { } ) : Promise < Endpoint > {
3636 const timing = ensureTiming ( options )
3737 const deadline = Date . now ( ) + timing . promiseTimeout
38- const contenders = new Set < Contender > ( )
38+ const contenders = new Set < ServiceContender > ( )
3939 let timeouts : { readonly info : Info ; readonly count : number } | undefined
4040 let announced = false
4141 let lastSpawn = 0
@@ -50,79 +50,63 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
5050 const [ command , ...args ] = options . command ?? [ "opencode" , "serve" , "--service" ]
5151 if ( command === undefined ) throw new Error ( "Missing service command" )
5252 try {
53- const child = spawn ( command , args , { detached : true , stdio : "ignore" } )
54- let error : Error | undefined
55- child . once ( "error" , ( cause ) => {
56- error = new Error ( "Failed to start server" , { cause } )
57- } )
58- child . unref ( )
59- return { child, error : ( ) => error }
53+ return spawnServiceContender ( command , args )
6054 } catch ( cause ) {
6155 throw new Error ( "Failed to start server" , { cause } )
6256 }
6357 }
6458
65- while ( true ) {
66- if ( Date . now ( ) >= deadline ) throw new Error ( "Timed out waiting for the background service to start" )
67- const registration = await registered ( options . file , true , timing . requestTimeout )
68- if ( registration . timedOut && registration . info !== undefined ) {
69- timeouts = {
70- info : registration . info ,
71- count : timeouts !== undefined && same ( timeouts . info , registration . info ) ? timeouts . count + 1 : 1 ,
72- }
73- if ( timeouts . count >= 3 ) {
74- announce ( "missing" )
75- await evict ( registration . info , options , timing )
76- timeouts = undefined
77- lastSpawn = Date . now ( ) - spawnDelay
78- }
79- } else timeouts = undefined
59+ try {
60+ while ( true ) {
61+ if ( Date . now ( ) >= deadline ) throw new Error ( "Timed out waiting for the background service to start" )
62+ const registration = await registered ( options . file , true , timing . requestTimeout )
63+ if ( registration . timedOut && registration . info !== undefined ) {
64+ timeouts = {
65+ info : registration . info ,
66+ count : timeouts !== undefined && same ( timeouts . info , registration . info ) ? timeouts . count + 1 : 1 ,
67+ }
68+ if ( timeouts . count >= 3 ) {
69+ announce ( "missing" )
70+ await evict ( registration . info , options , timing )
71+ timeouts = undefined
72+ lastSpawn = Date . now ( ) - spawnDelay
73+ }
74+ } else timeouts = undefined
8075
81- if ( registration . service !== undefined ) {
82- spawnDelay = timing . spawnDelay
83- const service = registration . service
84- const compatible = ! service . legacy && ( options . version === undefined || service . version === options . version )
85- if ( compatible && service . state === "ready" ) return service . endpoint
86- if ( compatible && service . state === "failed" ) throw new Error ( "Background service failed to start" )
87- if ( ! compatible ) {
88- announce ( "version-mismatch" , service . version )
89- await kill ( service , options , timing ) . catch ( ( ) => undefined )
90- lastSpawn = 0
91- }
92- } else {
93- if ( lastSpawn === 0 && registration . info !== undefined ) lastSpawn = Date . now ( )
94- const finished = [ ...contenders ] . filter ( contenderFinished )
95- const failure = finished . map ( contenderFailure ) . find ( ( error ) => error !== undefined )
96- if ( finished . some ( ( item ) => item . child . exitCode === 0 ) ) {
97- spawnDelay = Math . min ( spawnDelay * 2 , timing . maxSpawnDelay )
98- }
99- finished . forEach ( ( item ) => contenders . delete ( item ) )
100- if ( failure !== undefined && contenders . size === 0 ) throw failure
101- // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
102- if ( contenders . size < 2 && Date . now ( ) - lastSpawn >= spawnDelay ) {
103- announce ( "missing" )
104- contenders . add ( spawnContender ( ) )
105- lastSpawn = Date . now ( )
76+ if ( registration . service !== undefined ) {
77+ spawnDelay = timing . spawnDelay
78+ const service = registration . service
79+ const compatible = ! service . legacy && ( options . version === undefined || service . version === options . version )
80+ if ( compatible && service . state === "ready" ) return service . endpoint
81+ if ( compatible && service . state === "failed" ) throw new Error ( "Background service failed to start" )
82+ if ( ! compatible ) {
83+ announce ( "version-mismatch" , service . version )
84+ await kill ( service , options , timing ) . catch ( ( ) => undefined )
85+ lastSpawn = 0
86+ }
87+ } else {
88+ if ( lastSpawn === 0 && registration . info !== undefined ) lastSpawn = Date . now ( )
89+ const finished = [ ...contenders ] . filter ( contenderFinished )
90+ const failure = finished . map ( contenderFailure ) . find ( ( error ) => error !== undefined )
91+ if ( finished . some ( ( item ) => item . child . exitCode === 0 ) ) {
92+ spawnDelay = Math . min ( spawnDelay * 2 , timing . maxSpawnDelay )
93+ }
94+ finished . forEach ( ( item ) => contenders . delete ( item ) )
95+ if ( failure !== undefined && contenders . size === 0 ) throw failure
96+ // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
97+ if ( contenders . size < 2 && Date . now ( ) - lastSpawn >= spawnDelay ) {
98+ announce ( "missing" )
99+ contenders . add ( spawnContender ( ) )
100+ lastSpawn = Date . now ( )
101+ }
106102 }
103+ await delay ( timing . pollInterval )
107104 }
108- await delay ( timing . pollInterval )
105+ } finally {
106+ contenders . forEach ( ( contender ) => contender . release ( ) )
109107 }
110108}
111109
112- function contenderFailure ( contender : Contender ) {
113- const error = contender . error ( )
114- if ( error !== undefined ) return error
115- if ( contender . child . exitCode !== null && contender . child . exitCode !== 0 )
116- return new Error ( `Server process exited with code ${ contender . child . exitCode } ` )
117- if ( contender . child . signalCode !== null )
118- return new Error ( `Server process terminated by ${ contender . child . signalCode } ` )
119- return undefined
120- }
121-
122- function contenderFinished ( contender : Contender ) {
123- return contender . error ( ) !== undefined || contender . child . exitCode !== null || contender . child . signalCode !== null
124- }
125-
126110/** Stop the registered local service. */
127111export async function stop ( options : StopOptions = { } ) {
128112 const existing = await find ( options )
0 commit comments