From b33d950d2be3dbe138aced1f7529458f1f8c9755 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 19 Aug 2026 16:56:25 +0800 Subject: [PATCH] fix: avoid embedded opencode port conflicts --- .env.example | 2 +- src/config.ts | 4 +-- src/runtime/opencode.ts | 49 +++++++++++++++++++++++++++++++--- tests/runtime/opencode.test.ts | 32 +++++++++++++++++++++- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 29eb3eb..f9e557c 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ LOG_LEVEL=info OPENCODE_MODE=embedded OPENCODE_MODEL=deepseek/deepseek-v4-flash OPENCODE_HOSTNAME=127.0.0.1 -OPENCODE_PORT=4096 +OPENCODE_PORT=0 AGENT_LOCAL_USER_ID=local-user # 必须填写 Server /api/v1/projects 返回的项目 UUID。 diff --git a/src/config.ts b/src/config.ts index 047d1a6..c4116e0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,8 +58,8 @@ const envSchema = z OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), // embedded opencode server 的监听地址。 OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), - // embedded opencode server 的监听端口。 - OPENCODE_PORT: z.coerce.number().int().positive().default(4096), + // embedded opencode server 的监听端口;0 表示由系统动态分配。 + OPENCODE_PORT: z.coerce.number().int().nonnegative().default(0), // opencode SDK 启动或连接运行时时的超时时间(毫秒)。 OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), // 默认使用的 opencode 模型标识。 diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index c2db38e..9bf5993 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -4,6 +4,7 @@ import { type OpencodeClient, } from "@opencode-ai/sdk/v2"; import { existsSync, readFileSync } from "node:fs"; +import { createServer } from "node:net"; import { resolve } from "node:path"; import { config } from "../config.js"; @@ -44,6 +45,34 @@ type RuntimeMessage = { const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role; const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id; +const isTcpPortAvailable = (hostname: string, port: number) => + new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") { + resolve(false); + return; + } + reject(error); + }); + server.listen({ host: hostname, port, exclusive: true }, () => { + server.close((error) => (error ? reject(error) : resolve(true))); + }); + }); + +export const resolveEmbeddedOpencodePort = async ( + hostname: string, + configuredPort: number, +) => { + if (configuredPort === 0) { + return 0; + } + return (await isTcpPortAvailable(hostname, configuredPort)) + ? configuredPort + : 0; +}; + export class OpencodeRuntimeAdapter { private clientPromise: Promise | null = null; private closeServer: (() => void) | null = null; @@ -408,10 +437,24 @@ export class OpencodeRuntimeAdapter { process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + const port = await resolveEmbeddedOpencodePort( + config.OPENCODE_HOSTNAME, + config.OPENCODE_PORT, + ); + if (port !== config.OPENCODE_PORT) { + logger.warn( + { + hostname: config.OPENCODE_HOSTNAME, + configuredPort: config.OPENCODE_PORT, + }, + "configured opencode port is occupied; falling back to a dynamic port", + ); + } + logger.info( { hostname: config.OPENCODE_HOSTNAME, - port: config.OPENCODE_PORT, + port, model: config.OPENCODE_MODEL, mode: config.OPENCODE_MODE, }, @@ -423,7 +466,7 @@ export class OpencodeRuntimeAdapter { try { runtime = await createOpencode({ hostname: config.OPENCODE_HOSTNAME, - port: config.OPENCODE_PORT, + port, timeout: config.OPENCODE_TIMEOUT_MS, config: buildOpencodeConfig(), }); @@ -440,7 +483,7 @@ export class OpencodeRuntimeAdapter { { elapsedMs: Math.max(0, Date.now() - startedAt), hostname: config.OPENCODE_HOSTNAME, - port: config.OPENCODE_PORT, + baseUrl: runtime.server.url, mode: config.OPENCODE_MODE, }, "opencode server started in embedded mode", diff --git a/tests/runtime/opencode.test.ts b/tests/runtime/opencode.test.ts index 7fb4548..40d9793 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "bun:test"; import { type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { createServer } from "node:net"; import { config } from "../../src/config.js"; -import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; +import { + OpencodeRuntimeAdapter, + resolveEmbeddedOpencodePort, +} from "../../src/runtime/opencode.js"; const createRuntimeAdapter = ( messages: unknown[], @@ -87,6 +91,32 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => { }); }); +describe("resolveEmbeddedOpencodePort", () => { + it("falls back to a dynamic port when the configured port is occupied", async () => { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("expected a TCP server address"); + } + + try { + await expect( + resolveEmbeddedOpencodePort("127.0.0.1", address.port), + ).resolves.toBe(0); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); + describe("OpencodeRuntimeAdapter.warmup", () => { it("initializes the project session and model tools before reporting ready", async () => { const calls: string[] = [];