diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index cce3a2c53b9..bb2297d08e4 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -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((resume) => { const close = () => resume(Effect.void) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index b5bcd821794..b56ba1ac4d6 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -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, diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index db2d15e46e6..d3637675b53 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -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