From 7621aada79ee3e958c4a555436de56a24fb81af4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 5 Feb 2026 09:45:25 +0900 Subject: [PATCH] feat: auto port selection when default port is busy - Added port-utils module with isPortAvailable, findAvailablePort, getAvailableServerPort - Modified runner.ts to automatically find available port if preferred port is busy - Shows warning message when using auto-selected port - Eliminates need for manual OPENCODE_SERVER_PORT workaround --- bun.lock | 28 ++++----- src/cli/run/runner.ts | 23 +++++--- src/shared/index.ts | 1 + src/shared/port-utils.test.ts | 105 ++++++++++++++++++++++++++++++++++ src/shared/port-utils.ts | 48 ++++++++++++++++ 5 files changed, 183 insertions(+), 22 deletions(-) create mode 100644 src/shared/port-utils.test.ts create mode 100644 src/shared/port-utils.ts diff --git a/bun.lock b/bun.lock index 4ea340f4..b2412a61 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.2.2", - "oh-my-opencode-darwin-x64": "3.2.2", - "oh-my-opencode-linux-arm64": "3.2.2", - "oh-my-opencode-linux-arm64-musl": "3.2.2", - "oh-my-opencode-linux-x64": "3.2.2", - "oh-my-opencode-linux-x64-musl": "3.2.2", - "oh-my-opencode-windows-x64": "3.2.2", + "oh-my-opencode-darwin-arm64": "3.2.3", + "oh-my-opencode-darwin-x64": "3.2.3", + "oh-my-opencode-linux-arm64": "3.2.3", + "oh-my-opencode-linux-arm64-musl": "3.2.3", + "oh-my-opencode-linux-x64": "3.2.3", + "oh-my-opencode-linux-x64-musl": "3.2.3", + "oh-my-opencode-windows-x64": "3.2.3", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KyfoWcANfcvpfanrrX+Wc8vH8vr9mvr7dJMHBe2bkvuhdtHnLHOG18hQwLg6jk4HhdoZAeBEmkolOsK2k4XajA=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Doc9xQCj5Jmx3PzouBIfvDwmfWM94Y9Q9IngFqOjrVpfBef9V/WIH0PlhJU6ps4BKGey8Nf2afFq3UE06Z63Hg=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ajZ1E36Ixwdz6rvSUKUI08M2xOaNIl1ZsdVjknZTrPRtct9xgS+BEFCoSCov9bnV/9DrZD3mlZtO/+FFDbseUg=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-w7lO0Hn/AlLCHe33KPbje83Js2h5weDWVMuopEs6d3pi/1zkRDBEhCi63S4J0d0EKod9kEPQA6ojtdVJ4J39zQ=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ItJsYfigXcOa8/ejTjopC4qk5BCeYioMQ693kPTpeYHK3ByugTjJk8aamE7bHlVnmrdgWldz91QFzaP82yOAdg=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m1tS1jRLO2Svm5NuetK3BAgdAR8b2GkiIfMFoIYsLJTPmzIkXaigAYkFq+BXCs5JAbRmPmvjndz9cuCddnPADQ=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/TvjYe/Kb//ZSHnJzgRj0QPKpS5Y2nermVTSaMTGS2btObXQyQWzuphDhsVRu60SVrNLbflHzfuTdqb3avDjyA=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Q/0AGtOuUFGNGIX8F6iD5W8c2spbjrqVBPt0B7laQSwnScKs/BI+TvM6HRE37vhoWg+fzhAX3QYJ2H9Un9FYrg=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ka5j+tjuQkNnpESVzcTzW5tZMlBhOfP9F12+UaR72cIcwFpSoLMBp84rV6R0vXM0zUcrrN7mPeW66DvQ6A0XQQ=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RIAyoj2XbT8vH++5fPUkdO+D1tfqxh+iWto7CqWr1TgbABbBJljGk91HJgS9xjnxyCQJEpFhTmO7NMHKJcZOWQ=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ISl0sTNShKCgPFO+rsDqEDsvVHQAMfOSAxO0KuWbHFKaH+KaRV4d3N/ihgxZ2M94CZjJLzZEuln+6kLZ93cvzQ=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-nnQK3y7R4DrBvqdqRGbujL2oAAQnVVb23JHUbJPQ6YxrRRGWpLOVGvK5c16ykSFEUPl8eZDmi1ON/R4opKLOUw=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-KeiJLQvJuZ+UYf/+eMsQXvCiHDRPk6tD15lL+qruLvU19va62JqMNvTuOv97732uF19iG0ZMiiVhqIMbSyVPqQ=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-mt8E/TkpaCp04pvzwntT8x8TaqXDt3zCD5X2eA8ZZMrb5ofNr5HyG5G4SFXrUh+Ez3b/3YXpNWv6f6rnAlk1Dg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index a9bc7b12..c9c2527c 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -5,6 +5,7 @@ import { checkCompletionConditions } from "./completion" import { createEventState, processEvents, serializeError } from "./events" import type { OhMyOpenCodeConfig } from "../../config" import { loadPluginConfig } from "../../plugin-config" +import { getAvailableServerPort, DEFAULT_SERVER_PORT } from "../../shared/port-utils" const POLL_INTERVAL_MS = 500 const DEFAULT_TIMEOUT_MS = 0 @@ -89,7 +90,7 @@ export async function run(options: RunOptions): Promise { const pluginConfig = loadPluginConfig(directory, { command: "run" }) const resolvedAgent = resolveRunAgent(options, pluginConfig) - console.log(pc.cyan("Starting opencode server...")) + console.log(pc.cyan("Starting opencode server (auto port selection enabled)...")) const abortController = new AbortController() let timeoutId: ReturnType | null = null @@ -103,18 +104,24 @@ export async function run(options: RunOptions): Promise { } try { - // Support custom OpenCode server port via environment variable - // This allows Open Agent and other orchestrators to run multiple - // concurrent missions without port conflicts - const serverPort = process.env.OPENCODE_SERVER_PORT + const envPort = process.env.OPENCODE_SERVER_PORT ? parseInt(process.env.OPENCODE_SERVER_PORT, 10) : undefined - const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined + const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || "127.0.0.1" + const preferredPort = envPort && !isNaN(envPort) ? envPort : DEFAULT_SERVER_PORT + + const { port: serverPort, wasAutoSelected } = await getAvailableServerPort(preferredPort, serverHostname) + + if (wasAutoSelected) { + console.log(pc.yellow(`Port ${preferredPort} is busy, using port ${serverPort} instead`)) + } else { + console.log(pc.dim(`Using port ${serverPort}`)) + } const { client, server } = await createOpencode({ signal: abortController.signal, - ...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}), - ...(serverHostname ? { hostname: serverHostname } : {}), + port: serverPort, + hostname: serverHostname, }) const cleanup = () => { diff --git a/src/shared/index.ts b/src/shared/index.ts index 99b43262..93163dcc 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -40,3 +40,4 @@ export * from "./session-utils" export * from "./tmux" export * from "./model-suggestion-retry" export * from "./opencode-server-auth" +export * from "./port-utils" diff --git a/src/shared/port-utils.test.ts b/src/shared/port-utils.test.ts new file mode 100644 index 00000000..34ef34b9 --- /dev/null +++ b/src/shared/port-utils.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test" +import { + isPortAvailable, + findAvailablePort, + getAvailableServerPort, + DEFAULT_SERVER_PORT, +} from "./port-utils" + +describe("port-utils", () => { + describe("isPortAvailable", () => { + it("#given unused port #when checking availability #then returns true", async () => { + const port = 59999 + const result = await isPortAvailable(port) + expect(result).toBe(true) + }) + + it("#given port in use #when checking availability #then returns false", async () => { + const port = 59998 + const blocker = Bun.serve({ + port, + hostname: "127.0.0.1", + fetch: () => new Response("blocked"), + }) + + try { + const result = await isPortAvailable(port) + expect(result).toBe(false) + } finally { + blocker.stop(true) + } + }) + }) + + describe("findAvailablePort", () => { + it("#given start port available #when finding port #then returns start port", async () => { + const startPort = 59997 + const result = await findAvailablePort(startPort) + expect(result).toBe(startPort) + }) + + it("#given start port blocked #when finding port #then returns next available", async () => { + const startPort = 59996 + const blocker = Bun.serve({ + port: startPort, + hostname: "127.0.0.1", + fetch: () => new Response("blocked"), + }) + + try { + const result = await findAvailablePort(startPort) + expect(result).toBe(startPort + 1) + } finally { + blocker.stop(true) + } + }) + + it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { + const startPort = 59993 + const blockers = [ + Bun.serve({ port: startPort, hostname: "127.0.0.1", fetch: () => new Response() }), + Bun.serve({ port: startPort + 1, hostname: "127.0.0.1", fetch: () => new Response() }), + Bun.serve({ port: startPort + 2, hostname: "127.0.0.1", fetch: () => new Response() }), + ] + + try { + const result = await findAvailablePort(startPort) + expect(result).toBe(startPort + 3) + } finally { + blockers.forEach((b) => b.stop(true)) + } + }) + }) + + describe("getAvailableServerPort", () => { + it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { + const preferredPort = 59990 + const result = await getAvailableServerPort(preferredPort) + expect(result.port).toBe(preferredPort) + expect(result.wasAutoSelected).toBe(false) + }) + + it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { + const preferredPort = 59989 + const blocker = Bun.serve({ + port: preferredPort, + hostname: "127.0.0.1", + fetch: () => new Response("blocked"), + }) + + try { + const result = await getAvailableServerPort(preferredPort) + expect(result.port).toBeGreaterThan(preferredPort) + expect(result.wasAutoSelected).toBe(true) + } finally { + blocker.stop(true) + } + }) + }) + + describe("DEFAULT_SERVER_PORT", () => { + it("#given constant #when accessed #then returns 4096", () => { + expect(DEFAULT_SERVER_PORT).toBe(4096) + }) + }) +}) diff --git a/src/shared/port-utils.ts b/src/shared/port-utils.ts new file mode 100644 index 00000000..978a2658 --- /dev/null +++ b/src/shared/port-utils.ts @@ -0,0 +1,48 @@ +const DEFAULT_SERVER_PORT = 4096 +const MAX_PORT_ATTEMPTS = 20 + +export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise { + try { + const server = Bun.serve({ + port, + hostname, + fetch: () => new Response(), + }) + server.stop(true) + return true + } catch { + return false + } +} + +export async function findAvailablePort( + startPort: number = DEFAULT_SERVER_PORT, + hostname: string = "127.0.0.1" +): Promise { + for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) { + const port = startPort + attempt + if (await isPortAvailable(port, hostname)) { + return port + } + } + throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) +} + +export interface AutoPortResult { + port: number + wasAutoSelected: boolean +} + +export async function getAvailableServerPort( + preferredPort: number = DEFAULT_SERVER_PORT, + hostname: string = "127.0.0.1" +): Promise { + if (await isPortAvailable(preferredPort, hostname)) { + return { port: preferredPort, wasAutoSelected: false } + } + + const port = await findAvailablePort(preferredPort + 1, hostname) + return { port, wasAutoSelected: true } +} + +export { DEFAULT_SERVER_PORT }