fix: avoid embedded opencode port conflicts

This commit is contained in:
2026-08-19 16:56:25 +08:00
parent f0bf667d02
commit b33d950d2b
4 changed files with 80 additions and 7 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ LOG_LEVEL=info
OPENCODE_MODE=embedded OPENCODE_MODE=embedded
OPENCODE_MODEL=deepseek/deepseek-v4-flash OPENCODE_MODEL=deepseek/deepseek-v4-flash
OPENCODE_HOSTNAME=127.0.0.1 OPENCODE_HOSTNAME=127.0.0.1
OPENCODE_PORT=4096 OPENCODE_PORT=0
AGENT_LOCAL_USER_ID=local-user AGENT_LOCAL_USER_ID=local-user
# 必须填写 Server /api/v1/projects 返回的项目 UUID。 # 必须填写 Server /api/v1/projects 返回的项目 UUID。
+2 -2
View File
@@ -58,8 +58,8 @@ const envSchema = z
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
// embedded opencode server 的监听地址。 // embedded opencode server 的监听地址。
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
// embedded opencode server 的监听端口。 // embedded opencode server 的监听端口0 表示由系统动态分配
OPENCODE_PORT: z.coerce.number().int().positive().default(4096), OPENCODE_PORT: z.coerce.number().int().nonnegative().default(0),
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。 // opencode SDK 启动或连接运行时时的超时时间(毫秒)。
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。 // 默认使用的 opencode 模型标识。
+46 -3
View File
@@ -4,6 +4,7 @@ import {
type OpencodeClient, type OpencodeClient,
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { createServer } from "node:net";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
@@ -44,6 +45,34 @@ type RuntimeMessage = {
const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role; const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id; const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
const isTcpPortAvailable = (hostname: string, port: number) =>
new Promise<boolean>((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 { export class OpencodeRuntimeAdapter {
private clientPromise: Promise<OpencodeClient> | null = null; private clientPromise: Promise<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null; private closeServer: (() => void) | null = null;
@@ -408,10 +437,24 @@ export class OpencodeRuntimeAdapter {
process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? 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( logger.info(
{ {
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, port,
model: config.OPENCODE_MODEL, model: config.OPENCODE_MODEL,
mode: config.OPENCODE_MODE, mode: config.OPENCODE_MODE,
}, },
@@ -423,7 +466,7 @@ export class OpencodeRuntimeAdapter {
try { try {
runtime = await createOpencode({ runtime = await createOpencode({
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, port,
timeout: config.OPENCODE_TIMEOUT_MS, timeout: config.OPENCODE_TIMEOUT_MS,
config: buildOpencodeConfig(), config: buildOpencodeConfig(),
}); });
@@ -440,7 +483,7 @@ export class OpencodeRuntimeAdapter {
{ {
elapsedMs: Math.max(0, Date.now() - startedAt), elapsedMs: Math.max(0, Date.now() - startedAt),
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, baseUrl: runtime.server.url,
mode: config.OPENCODE_MODE, mode: config.OPENCODE_MODE,
}, },
"opencode server started in embedded mode", "opencode server started in embedded mode",
+31 -1
View File
@@ -1,8 +1,12 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { type OpencodeClient } from "@opencode-ai/sdk/v2"; import { type OpencodeClient } from "@opencode-ai/sdk/v2";
import { createServer } from "node:net";
import { config } from "../../src/config.js"; import { config } from "../../src/config.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import {
OpencodeRuntimeAdapter,
resolveEmbeddedOpencodePort,
} from "../../src/runtime/opencode.js";
const createRuntimeAdapter = ( const createRuntimeAdapter = (
messages: unknown[], 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<void>((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<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
describe("OpencodeRuntimeAdapter.warmup", () => { describe("OpencodeRuntimeAdapter.warmup", () => {
it("initializes the project session and model tools before reporting ready", async () => { it("initializes the project session and model tools before reporting ready", async () => {
const calls: string[] = []; const calls: string[] = [];