@@ -10,6 +10,9 @@ import { TestClock } from "effect/testing"
1010import fs from "node:fs/promises"
1111import os from "node:os"
1212import path from "node:path"
13+ import { LayerNode } from "@opencode/util/effect/layer-node"
14+ import { AppProcess } from "@opencode/util/process"
15+ import { PortOwner } from "../src/services/port-owner"
1316import { ServiceConfig } from "../src/services/service-config"
1417import { ServiceRegistration } from "../src/services/service-registration"
1518import { isolatedEnv , transpilerCache } from "./fixture/environment"
@@ -192,6 +195,72 @@ test("service config manages environment variables", async () => {
192195 }
193196} )
194197
198+ test ( "saved service environment cannot move the registration's state directory" , async ( ) => {
199+ expect ( ServiceConfig . processEnv ( undefined ) ) . toBeUndefined ( )
200+ expect ( ServiceConfig . processEnv ( { XDG_STATE_HOME : "/custom/state" , XDG_DATA_HOME : "/custom/data" , A : "1" } ) ) . toEqual ( {
201+ XDG_DATA_HOME : "/custom/data" ,
202+ A : "1" ,
203+ } )
204+
205+ const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-service-env-state-" ) )
206+ const layer = Global . layerWith ( { config : path . join ( root , "config" ) , state : path . join ( root , "state" ) } )
207+ try {
208+ await fs . mkdir ( path . join ( root , "config" ) , { recursive : true } )
209+ await Bun . write (
210+ path . join ( root , "config" , ServiceConfig . filename ( ) ) ,
211+ JSON . stringify ( { env : { XDG_STATE_HOME : path . join ( root , "elsewhere" ) , A : "1" } } ) ,
212+ )
213+ const options = await Effect . runPromise (
214+ ServiceConfig . options ( ) . pipe ( Effect . provide ( layer ) , Effect . provide ( NodeFileSystem . layer ) ) ,
215+ )
216+ expect ( options . env ) . toEqual ( { A : "1" } )
217+ expect ( options . file ) . toBe ( path . join ( root , "state" , ServiceConfig . filename ( ) ) )
218+ } finally {
219+ await fs . rm ( root , { recursive : true , force : true } )
220+ }
221+ } )
222+
223+ test . skipIf ( process . platform === "win32" || ! Bun . which ( "lsof" ) ) ( "port owner names the listening process" , async ( ) => {
224+ using listener = Bun . serve ( { hostname : "127.0.0.1" , port : 0 , fetch : ( ) => new Response ( ) } )
225+ using wildcard = Bun . serve ( { hostname : "0.0.0.0" , port : 0 , fetch : ( ) => new Response ( ) } )
226+ const describe = ( port : number | undefined , hostname ?: string ) =>
227+ Effect . runPromise ( PortOwner . describe ( port ?? 0 , hostname ) . pipe ( Effect . provide ( LayerNode . compile ( AppProcess . node ) ) ) )
228+ expect ( await describe ( listener . port ) ) . toStartWith ( `PID ${ process . pid } (` )
229+ expect ( await describe ( listener . port , "127.0.0.1" ) ) . toStartWith ( `PID ${ process . pid } (` )
230+ expect ( await describe ( listener . port , "localhost" ) ) . toStartWith ( `PID ${ process . pid } (` )
231+ expect ( await describe ( listener . port , "127.0.0.2" ) ) . toBeUndefined ( )
232+ expect ( await describe ( wildcard . port , "127.0.0.2" ) ) . toStartWith ( `PID ${ process . pid } (` )
233+ } )
234+
235+ test . skipIf ( process . platform === "win32" ) ( "port owner gives up on a lookup that ignores SIGTERM" , async ( ) => {
236+ const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "opencode-port-owner-stuck-" ) )
237+ const PATH = process . env . PATH
238+ try {
239+ await Bun . write ( path . join ( root , "lsof" ) , "#!/bin/sh\ntrap '' TERM\nsleep 10\n" )
240+ await fs . chmod ( path . join ( root , "lsof" ) , 0o755 )
241+ process . env . PATH = `${ root } ${ path . delimiter } ${ PATH } `
242+ const started = Date . now ( )
243+ const owner = await Promise . race ( [
244+ Effect . runPromise ( PortOwner . describe ( 1 ) . pipe ( Effect . provide ( LayerNode . compile ( AppProcess . node ) ) ) ) ,
245+ Bun . sleep ( 4_000 ) . then ( ( ) => "hung" ) ,
246+ ] )
247+ expect ( owner ) . toBeUndefined ( )
248+ expect ( Date . now ( ) - started ) . toBeLessThan ( 3_000 )
249+ } finally {
250+ process . env . PATH = PATH
251+ await fs . rm ( root , { recursive : true , force : true } )
252+ }
253+ } )
254+
255+ test . skipIf ( process . platform === "win32" ) ( "port owner is unknown for a free port" , async ( ) => {
256+ const free = Bun . serve ( { hostname : "127.0.0.1" , port : 0 , fetch : ( ) => new Response ( ) } )
257+ const port = free . port ?? 0
258+ free . stop ( true )
259+ expect (
260+ await Effect . runPromise ( PortOwner . describe ( port ) . pipe ( Effect . provide ( LayerNode . compile ( AppProcess . node ) ) ) ) ,
261+ ) . toBeUndefined ( )
262+ } )
263+
195264test ( "service filenames share release channels and identify preview channels" , ( ) => {
196265 expect ( ServiceConfig . filename ( "latest" ) ) . toBe ( "service.json" )
197266 expect ( ServiceConfig . filename ( "dev" ) ) . toBe ( "service.json" )
@@ -548,7 +617,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
548617 await fs . mkdir ( path . dirname ( registration ) , { recursive : true } )
549618 await fs . writeFile (
550619 path . join ( root , "config" , "opencode" , "service-local.json" ) ,
551- JSON . stringify ( { port : listener . port } ) ,
620+ JSON . stringify ( { port : listener . port , env : { XDG_STATE_HOME : path . join ( root , "elsewhere" ) } } ) ,
552621 )
553622 const stale = {
554623 id : "stale" ,
@@ -558,8 +627,17 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
558627 password : "stale" ,
559628 }
560629 await fs . writeFile ( registration , JSON . stringify ( stale ) )
630+ // The port lookup is a child of the service, so it shows the state directory tools would inherit.
631+ const lsof = process . platform === "win32" ? null : Bun . which ( "lsof" )
632+ if ( lsof ) {
633+ await Bun . write (
634+ path . join ( root , "bin" , "lsof" ) ,
635+ `#!/bin/sh\nprintf %s "$XDG_STATE_HOME" > "${ path . join ( root , "inherited-state" ) } "\nexec "${ lsof } " "$@"\n` ,
636+ )
637+ await fs . chmod ( path . join ( root , "bin" , "lsof" ) , 0o755 )
638+ }
561639 const contender = Bun . spawn ( [ process . execPath , path . join ( import . meta. dir , "../src/index.ts" ) , "serve" , "--service" ] , {
562- env : serviceEnv ( root ) ,
640+ env : { ... serviceEnv ( root ) , PATH : ` ${ path . join ( root , "bin" ) } ${ path . delimiter } ${ process . env . PATH } ` } ,
563641 stderr : "pipe" ,
564642 stdout : "pipe" ,
565643 } )
@@ -569,7 +647,12 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
569647 const exitCode = await Promise . race ( [ contender . exited , Bun . sleep ( 20_000 ) . then ( ( ) => undefined ) ] )
570648 expect ( exitCode ) . toBe ( 1 )
571649 const output = ( await new Response ( contender . stdout ) . text ( ) ) + ( await new Response ( contender . stderr ) . text ( ) )
572- expect ( output ) . toContain ( `Managed service port ${ listener . port } on 127.0.0.1 is already in use by another process` )
650+ expect ( output ) . toContain (
651+ `Managed service port ${ listener . port } on 127.0.0.1 is already in use by ${
652+ lsof ? `PID ${ process . pid } (` : "another process"
653+ } `,
654+ )
655+ if ( lsof ) expect ( await Bun . file ( path . join ( root , "inherited-state" ) ) . text ( ) ) . toBe ( path . join ( root , "state" ) )
573656 expect ( await Bun . file ( registration ) . json ( ) ) . toEqual ( stale )
574657 } finally {
575658 contender . kill ( "SIGTERM" )
0 commit comments