Merge pull request #1499 from code-yeongyu/feat/auto-port-selection

feat: auto port selection when default port is busy
This commit is contained in:
YeonGyu-Kim
2026-02-05 09:59:11 +09:00
committed by GitHub
4 changed files with 169 additions and 8 deletions

View File

@@ -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<number> {
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<typeof setTimeout> | null = null
@@ -103,18 +104,24 @@ export async function run(options: RunOptions): Promise<number> {
}
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 = () => {

View File

@@ -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"

View File

@@ -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)
})
})
})

48
src/shared/port-utils.ts Normal file
View File

@@ -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<boolean> {
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<number> {
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<AutoPortResult> {
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 }