fix(cli): fall back from unavailable managed service port

This commit is contained in:
Hona
2026-09-23 08:21:16 +00:00
parent 9c8a63e852
commit 9120e07cc9
3 changed files with 167 additions and 49 deletions
+60 -46
View File
@@ -59,10 +59,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
const configuredPort = options.port ?? config.port
const port = configuredPort ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
const incumbent =
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
serviceOptions !== undefined
? yield* Service.incumbent(
configuredPort === undefined
? serviceOptions
: { ...serviceOptions, url: serviceURL(hostname, configuredPort) },
)
: undefined
if (incumbent !== undefined) return
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
@@ -84,46 +89,43 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const server = yield* start(
{
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
hostname,
port,
cors: options.cors ?? config.cors,
password,
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: databasePath(global.data),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
file: process.env.OPENCODE_MODELS_PATH,
fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH),
},
config: {
directory: process.env.OPENCODE_CONFIG_DIR,
project: !truthy(
process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
),
file: process.env.OPENCODE_CONFIG,
content: process.env.OPENCODE_CONFIG_CONTENT,
},
windows: {
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
},
fs: {
filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
fff:
process.env.OPENCODE_DISABLE_FFF === undefined
? process.platform !== "win32"
: !truthy(process.env.OPENCODE_DISABLE_FFF),
},
const serverOptions = {
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
hostname,
cors: options.cors ?? config.cors,
password,
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
path: databasePath(global.data),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
file: process.env.OPENCODE_MODELS_PATH,
fetch: !truthy(process.env.OPENCODE_DISABLE_MODELS_FETCH),
},
config: {
directory: process.env.OPENCODE_CONFIG_DIR,
project: !truthy(process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG),
file: process.env.OPENCODE_CONFIG,
content: process.env.OPENCODE_CONFIG_CONTENT,
},
windows: {
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
},
fs: {
filewatcher: !truthy(process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER),
fff:
process.env.OPENCODE_DISABLE_FFF === undefined
? process.platform !== "win32"
: !truthy(process.env.OPENCODE_DISABLE_FFF),
},
}
const lifecycle =
serviceOptions === undefined
? undefined
: {
@@ -138,11 +140,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
shutdown,
})
}),
},
transform,
).pipe(
}
const startOnPort = (selectedPort?: number) =>
start({ ...serverOptions, port: selectedPort }, lifecycle, transform)
const server = yield* startOnPort(port).pipe(
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
if (serviceOptions === undefined || port === undefined) return Effect.fail(error)
if (configuredPort === undefined && (addressInUse(error) || accessDenied(error)))
return Service.incumbent(serviceOptions).pipe(
Effect.flatMap((found) => (found ? Effect.void : startOnPort(undefined))),
)
if (!addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
Effect.flatMap((found) =>
found
@@ -194,6 +202,12 @@ function addressInUse(error: unknown): boolean {
return "cause" in error && addressInUse(error.cause)
}
function accessDenied(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false
if ("code" in error && error.code === "EACCES") return true
return "cause" in error && accessDenied(error.cause)
}
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
+104
View File
@@ -316,6 +316,95 @@ test("configured managed service port overrides the channel default", async () =
}
}, 30_000)
test("unconfigured managed service skips occupied ports and registers the selected address", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-auto-port-"))
const previousDefaultPort = 0xc0df
// An existing service may already own either port on a developer machine.
const occupied = await Promise.all([4096, previousDefaultPort].map(reservePort))
const registration = path.join(root, "state", "opencode", "service-local.json")
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try {
const info = await Promise.race([
waitForInfo(registration),
owner.exited.then(() => {
throw new Error("Service exited before registering a fallback port")
}),
])
expect(new URL(info.url).port).not.toBe(String(previousDefaultPort))
expect(new URL(info.url).port).not.toBe("4096")
expect(
await Effect.runPromise(Service.discover({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer))),
).toMatchObject({
url: info.url,
})
} finally {
owner.kill("SIGTERM")
await owner.exited
occupied.forEach((server) => server?.stop(true))
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test("concurrent unconfigured services converge on one registered owner", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-auto-election-"))
const occupied = await reservePort(0xc0df)
const registration = path.join(root, "state", "opencode", "service-local.json")
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const processes = Array.from({ length: 2 }, () =>
Bun.spawn(command, { env: serviceEnv(root), stderr: "pipe", stdout: "ignore" }),
)
try {
const exited = await Promise.race([
...processes.map((process, index) => process.exited.then((code) => ({ index, code }))),
Bun.sleep(20_000).then(() => undefined),
])
if (!exited) throw new Error("Concurrent services did not settle")
expect(exited.code).toBe(0)
const winner = processes[1 - exited.index]!
const info = await waitForInfo(registration, (value) => value.pid === winner.pid)
expect(winner.exitCode).toBeNull()
await Bun.sleep(5_500)
expect((await Bun.file(registration).json()).id).toBe(info.id)
expect(winner.exitCode).toBeNull()
const contender = Bun.spawn(command, { env: serviceEnv(root), stderr: "pipe", stdout: "ignore" })
expect(await contender.exited).toBe(0)
expect((await Bun.file(registration).json()).id).toBe(info.id)
} finally {
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.map((process) => process.exited))
occupied?.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
}, 35_000)
test("a failed service on a fallback port retains its registered owner", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-auto-failed-"))
const occupied = await reservePort(0xc0df)
const database = path.join(root, "database")
await fs.mkdir(database)
const registration = path.join(root, "state", "opencode", "service-local.json")
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
const env = { ...serviceEnv(root), OPENCODE_DB: database }
const owner = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
const info = await waitForInfo(registration)
await waitForFailed(info)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
expect(await contender.exited).toBe(0)
expect((await Bun.file(registration).json()).id).toBe(info.id)
expect(owner.exitCode).toBeNull()
} finally {
owner.kill("SIGTERM")
await owner.exited
occupied?.stop(true)
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
test.each([
{ args: [], origins: ["http://192.0.2.10:3001", "https://configured.example.com"] },
{
@@ -599,6 +688,21 @@ async function availablePort() {
return port
}
async function reservePort(port: number) {
return Effect.runPromise(
Effect.try({
try: () => Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response("unrelated") }),
catch: (cause) => cause,
}).pipe(
Effect.catch((error) =>
typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE"
? Effect.succeed(undefined)
: Effect.fail(error),
),
),
)
}
function serviceEnv(root: string) {
return {
...process.env,
+3 -3
View File
@@ -35,12 +35,12 @@ export const discover = Effect.fn("service.discover")(function* (options: Discov
return found.endpoint
})
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
/** Recognize an authenticated compatible service, optionally at an expected URL, including while it starts or fails. */
export const incumbent = Effect.fn("service.incumbent")(function* (
options: DiscoverOptions & { readonly url: string },
options: DiscoverOptions & { readonly url?: string },
) {
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url ?? info.url })
if (found === undefined) return undefined
if (!found.compatible) return undefined
if (!matchesVersion(found.version, options)) return undefined