11import { NodeFileSystem } from "@effect/platform-node"
2- import { Service } from "@opencode-ai/client/effect/service"
2+ import { Service , type Info } from "@opencode-ai/client/effect/service"
33import { Database } from "@opencode-ai/core/database/database"
44import { EventV2 } from "@opencode-ai/core/event"
55import { EventTable } from "@opencode-ai/core/event/sql"
@@ -17,6 +17,13 @@ import os from "node:os"
1717import path from "node:path"
1818import { ServiceConfig } from "../src/services/service-config"
1919
20+ test ( "managed service ports are stable per installation channel" , ( ) => {
21+ expect ( ServiceConfig . defaultPort ( "latest" ) ) . toBe ( 0xc0de )
22+ expect ( ServiceConfig . defaultPort ( "local" ) ) . toBe ( 0xc0df )
23+ expect ( ServiceConfig . defaultPort ( "preview-a" ) ) . toBe ( ServiceConfig . defaultPort ( "preview-a" ) )
24+ expect ( ServiceConfig . defaultPort ( "preview-a" ) ) . not . toBe ( ServiceConfig . defaultPort ( "preview-b" ) )
25+ } )
26+
2027test ( "local channel stores service config with the local service filename" , async ( ) => {
2128 const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-service-" ) )
2229 try {
@@ -130,18 +137,23 @@ test("concurrent service processes elect one server", async () => {
130137 )
131138 const command = [ process . execPath , path . join ( import . meta. dir , "../src/index.ts" ) , "serve" , "--service" ]
132139 const registration = path . join ( root , "state" , "opencode" , "service-local.json" )
140+ const port = await availablePort ( )
141+ await fs . mkdir ( path . join ( root , "config" , "opencode" ) , { recursive : true } )
142+ await fs . writeFile ( path . join ( root , "config" , "opencode" , "service-local.json" ) , JSON . stringify ( { port } ) )
133143 const processes = Array . from ( { length : 10 } , ( ) => Bun . spawn ( command , { env, stderr : "pipe" , stdout : "ignore" } ) )
134144
135145 try {
136146 const info = await waitForInfo ( registration )
137147 const winner = processes . find ( ( process ) => process . pid === info . pid )
138148 const losers = processes . filter ( ( process ) => process . pid !== info . pid )
139149 const exited = await Promise . all (
140- losers . map ( ( process ) => Promise . race ( [ process . exited . then ( ( ) => true ) , Bun . sleep ( 10_000 ) . then ( ( ) => false ) ] ) ) ,
150+ losers . map ( ( process ) => Promise . race ( [ process . exited . then ( ( ) => true ) , Bun . sleep ( 60_000 ) . then ( ( ) => false ) ] ) ) ,
141151 )
142152
143153 expect ( exited ) . toEqual ( losers . map ( ( ) => true ) )
144154 expect ( winner ?. exitCode ) . toBe ( null )
155+ expect ( new URL ( info . url ) . port ) . toBe ( String ( port ) )
156+ expect ( await Bun . file ( registration + ".lock" ) . exists ( ) ) . toBe ( false )
145157 expect (
146158 await fetch ( new URL ( "/api/health" , info . url ) , {
147159 headers : { authorization : "Basic " + btoa ( `opencode:${ info . password } ` ) } ,
@@ -154,16 +166,31 @@ test("concurrent service processes elect one server", async () => {
154166 const blockedTemp = registration + "." + info . id + ".tmp"
155167 await fs . mkdir ( blockedTemp )
156168 await fs . rm ( registration )
157- await Bun . sleep ( 6_000 )
169+ const repairContender = Bun . spawn ( command , { env, stderr : "pipe" , stdout : "ignore" } )
170+ await Bun . sleep ( 3_000 )
158171 expect ( await Bun . file ( registration ) . exists ( ) ) . toBe ( false )
159172 await fs . rm ( blockedTemp , { recursive : true } )
173+ expect ( await Promise . race ( [ repairContender . exited . then ( ( ) => true ) , Bun . sleep ( 15_000 ) . then ( ( ) => false ) ] ) ) . toBe (
174+ true ,
175+ )
176+ expect ( repairContender . exitCode ) . toBe ( 0 )
160177 const restored = await waitForInfo ( registration )
161178 expect ( restored . id ) . toBe ( info . id )
162179 expect ( restored . pid ) . toBe ( info . pid )
163180 await fs . writeFile ( registration , "not-json" )
164181 const repaired = await waitForInfo ( registration )
165182 expect ( repaired . id ) . toBe ( info . id )
166183 expect ( repaired . pid ) . toBe ( info . pid )
184+ await fs . writeFile (
185+ registration ,
186+ JSON . stringify ( { ...info , id : "older-orphan" , pid : process . pid , startedAt : info . startedAt ! - 1 } ) ,
187+ )
188+ const reclaimed = await waitForInfo ( registration , ( value ) => value . id === info . id )
189+ expect ( reclaimed . pid ) . toBe ( info . pid )
190+ await fs . writeFile ( registration , JSON . stringify ( { ...info , id : "newer-owner" , startedAt : info . startedAt ! + 1 } ) )
191+ await Bun . sleep ( 6_000 )
192+ expect ( ( await waitForInfo ( registration ) ) . id ) . toBe ( "newer-owner" )
193+ await fs . writeFile ( registration , JSON . stringify ( info ) )
167194
168195 const contender = Bun . spawn ( command , { env, stderr : "pipe" , stdout : "ignore" } )
169196 try {
@@ -202,9 +229,88 @@ test("concurrent service processes elect one server", async () => {
202229 await fs . rm ( root , { recursive : true , force : true } )
203230 }
204231 }
205- } , 60_000 )
232+ } , 120_000 )
233+
234+ test ( "configured managed service port overrides the channel default" , async ( ) => {
235+ const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-service-port-" ) )
236+ const port = await availablePort ( )
237+ const env = serviceEnv ( root )
238+ const registration = path . join ( root , "state" , "opencode" , "service-local.json" )
239+ await fs . mkdir ( path . join ( root , "config" , "opencode" ) , { recursive : true } )
240+ await fs . writeFile ( path . join ( root , "config" , "opencode" , "service-local.json" ) , JSON . stringify ( { port } ) )
241+ const owner = Bun . spawn ( [ process . execPath , path . join ( import . meta. dir , "../src/index.ts" ) , "serve" , "--service" ] , {
242+ env,
243+ stderr : "pipe" ,
244+ stdout : "ignore" ,
245+ } )
246+ try {
247+ const info = await waitForInfo ( registration )
248+ expect ( new URL ( info . url ) . port ) . toBe ( String ( port ) )
249+ await Effect . runPromise ( Service . stop ( { file : registration } ) . pipe ( Effect . provide ( NodeFileSystem . layer ) ) )
250+ await owner . exited
251+ } finally {
252+ owner . kill ( "SIGTERM" )
253+ await owner . exited
254+ await fs . rm ( root , { recursive : true , force : true } )
255+ }
256+ } , 30_000 )
257+
258+ test ( "unrelated managed port occupancy reports an actionable conflict" , async ( ) => {
259+ const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-service-conflict-" ) )
260+ const listener = Bun . serve ( { port : 0 , fetch : ( ) => new Response ( "unrelated" ) } )
261+ const port = listener . port
262+ const registration = path . join ( root , "state" , "opencode" , "service-local.json" )
263+ await fs . mkdir ( path . join ( root , "config" , "opencode" ) , { recursive : true } )
264+ await fs . writeFile ( path . join ( root , "config" , "opencode" , "service-local.json" ) , JSON . stringify ( { port } ) )
265+ const contender = Bun . spawn ( [ process . execPath , path . join ( import . meta. dir , "../src/index.ts" ) , "serve" , "--service" ] , {
266+ env : serviceEnv ( root ) ,
267+ stderr : "pipe" ,
268+ stdout : "pipe" ,
269+ } )
270+ try {
271+ expect ( await contender . exited ) . not . toBe ( 0 )
272+ const output = ( await new Response ( contender . stdout ) . text ( ) ) + ( await new Response ( contender . stderr ) . text ( ) )
273+ expect ( output ) . toContain ( `Managed service port ${ port } on 127.0.0.1 is already in use by another process` )
274+ expect ( output ) . toContain ( "opencode service set port <port>" )
275+ expect ( await Bun . file ( registration ) . exists ( ) ) . toBe ( false )
276+ } finally {
277+ listener . stop ( true )
278+ contender . kill ( "SIGTERM" )
279+ await contender . exited
280+ await fs . rm ( root , { recursive : true , force : true } )
281+ }
282+ } , 30_000 )
283+
284+ test ( "stale dead registration is replaced after binding the selected port" , async ( ) => {
285+ const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-service-stale-" ) )
286+ const port = await availablePort ( )
287+ const registration = path . join ( root , "state" , "opencode" , "service-local.json" )
288+ await fs . mkdir ( path . join ( root , "config" , "opencode" ) , { recursive : true } )
289+ await fs . mkdir ( path . dirname ( registration ) , { recursive : true } )
290+ await fs . writeFile ( path . join ( root , "config" , "opencode" , "service-local.json" ) , JSON . stringify ( { port } ) )
291+ await fs . writeFile (
292+ registration ,
293+ JSON . stringify ( { id : "dead" , version : "dead" , url : `http://127.0.0.1:${ port } ` , pid : 2_147_483_647 } ) ,
294+ )
295+ const owner = Bun . spawn ( [ process . execPath , path . join ( import . meta. dir , "../src/index.ts" ) , "serve" , "--service" ] , {
296+ env : serviceEnv ( root ) ,
297+ stderr : "pipe" ,
298+ stdout : "ignore" ,
299+ } )
300+ try {
301+ const info = await waitForInfo ( registration , ( value ) => value . id !== "dead" )
302+ expect ( new URL ( info . url ) . port ) . toBe ( String ( port ) )
303+ expect ( info . pid ) . toBe ( owner . pid )
304+ await Effect . runPromise ( Service . stop ( { file : registration } ) . pipe ( Effect . provide ( NodeFileSystem . layer ) ) )
305+ await owner . exited
306+ } finally {
307+ owner . kill ( "SIGTERM" )
308+ await owner . exited
309+ await fs . rm ( root , { recursive : true , force : true } )
310+ }
311+ } , 30_000 )
206312
207- test ( "a failed service stays registered and owns the lock until stopped" , async ( ) => {
313+ test ( "a failed service stays registered and owns the selected port until stopped" , async ( ) => {
208314 const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-service-failed-" ) )
209315 const database = path . join ( root , "database" )
210316 await fs . mkdir ( database )
@@ -275,13 +381,36 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
275381 )
276382}
277383
278- async function waitForInfo ( file : string ) {
384+ async function waitForInfo ( file : string , accept : ( info : Info ) => boolean = ( ) => true ) {
279385 for ( let attempt = 0 ; attempt < 400 ; attempt ++ ) {
280386 const value = await Bun . file ( file )
281387 . json ( )
282388 . catch ( ( ) => undefined )
283- if ( value !== undefined ) return Schema . decodeUnknownPromise ( Service . Info ) ( value )
389+ if ( value !== undefined ) {
390+ const info = await Schema . decodeUnknownPromise ( Service . Info ) ( value )
391+ if ( accept ( info ) ) return info
392+ }
284393 await Bun . sleep ( 50 )
285394 }
286395 throw new Error ( "Timed out waiting for service registration" )
287396}
397+
398+ async function availablePort ( ) {
399+ const server = Bun . serve ( { port : 0 , fetch : ( ) => new Response ( ) } )
400+ const port = server . port
401+ await server . stop ( true )
402+ return port
403+ }
404+
405+ function serviceEnv ( root : string ) {
406+ return {
407+ ...process . env ,
408+ HOME : root ,
409+ OPENCODE_DB : path . join ( root , "opencode.db" ) ,
410+ OPENCODE_TEST_HOME : root ,
411+ XDG_CACHE_HOME : path . join ( root , "cache" ) ,
412+ XDG_CONFIG_HOME : path . join ( root , "config" ) ,
413+ XDG_DATA_HOME : path . join ( root , "data" ) ,
414+ XDG_STATE_HOME : path . join ( root , "state" ) ,
415+ }
416+ }
0 commit comments