fix(cli-run): retry server start on port binding race condition

When port appears available but binding fails (race with another opencode
instance), retry on next available port (auto mode) or attach to existing
server (explicit port mode) instead of crashing with exit code 1.
This commit is contained in:
YeonGyu-Kim 2026-02-18 13:45:41 +09:00
parent b1c43aeb89
commit 1e05f4770e
2 changed files with 90 additions and 10 deletions

View File

@ -95,6 +95,24 @@ describe("createServerConnection", () => {
expect(mockServerClose).toHaveBeenCalled()
})
it("explicit port attaches when start fails because port became occupied", async () => {
// given
const signal = new AbortController().signal
const port = 8080
mockIsPortAvailable.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
mockCreateOpencode.mockRejectedValueOnce(new Error("Failed to start server on port 8080"))
// when
const result = await createServerConnection({ port, signal })
// then
expect(mockIsPortAvailable).toHaveBeenNthCalledWith(1, 8080, "127.0.0.1")
expect(mockIsPortAvailable).toHaveBeenNthCalledWith(2, 8080, "127.0.0.1")
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" })
result.cleanup()
expect(mockServerClose).not.toHaveBeenCalled()
})
it("explicit port attaches when port is occupied", async () => {
// given
const signal = new AbortController().signal
@ -133,6 +151,32 @@ describe("createServerConnection", () => {
expect(mockServerClose).toHaveBeenCalled()
})
it("auto mode retries on next port when initial start fails", async () => {
// given
const signal = new AbortController().signal
mockGetAvailableServerPort
.mockResolvedValueOnce({ port: 4096, wasAutoSelected: false })
.mockResolvedValueOnce({ port: 4097, wasAutoSelected: true })
mockCreateOpencode
.mockRejectedValueOnce(new Error("Failed to start server on port 4096"))
.mockResolvedValueOnce({
client: { session: {} },
server: { url: "http://127.0.0.1:4097", close: mockServerClose },
})
// when
const result = await createServerConnection({ signal })
// then
expect(mockGetAvailableServerPort).toHaveBeenNthCalledWith(1, 4096, "127.0.0.1")
expect(mockGetAvailableServerPort).toHaveBeenNthCalledWith(2, 4097, "127.0.0.1")
expect(mockCreateOpencode).toHaveBeenNthCalledWith(1, { signal, port: 4096, hostname: "127.0.0.1" })
expect(mockCreateOpencode).toHaveBeenNthCalledWith(2, { signal, port: 4097, hostname: "127.0.0.1" })
result.cleanup()
expect(mockServerClose).toHaveBeenCalledTimes(1)
})
it("invalid port throws error", async () => {
// given
const signal = new AbortController().signal

View File

@ -5,6 +5,24 @@ import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "..
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
import { prependResolvedOpencodeBinToPath } from "./opencode-bin-path"
function isPortStartFailure(error: unknown, port: number): boolean {
if (!(error instanceof Error)) {
return false
}
return error.message.includes(`Failed to start server on port ${port}`)
}
async function startServer(options: { signal: AbortSignal, port: number }): Promise<ServerConnection> {
const { signal, port } = options
const { client, server } = await withWorkingOpencodePath(() =>
createOpencode({ signal, port, hostname: "127.0.0.1" }),
)
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
}
export async function createServerConnection(options: {
port?: number
attach?: string
@ -29,11 +47,22 @@ export async function createServerConnection(options: {
if (available) {
console.log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
const { client, server } = await withWorkingOpencodePath(() =>
createOpencode({ signal, port, hostname: "127.0.0.1" }),
)
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
try {
return await startServer({ signal, port })
} catch (error) {
if (!isPortStartFailure(error, port)) {
throw error
}
const stillAvailable = await isPortAvailable(port, "127.0.0.1")
if (stillAvailable) {
throw error
}
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
return { client, cleanup: () => {} }
}
}
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
@ -47,9 +76,16 @@ export async function createServerConnection(options: {
} else {
console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
}
const { client, server } = await withWorkingOpencodePath(() =>
createOpencode({ signal, port: selectedPort, hostname: "127.0.0.1" }),
)
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
try {
return await startServer({ signal, port: selectedPort })
} catch (error) {
if (!isPortStartFailure(error, selectedPort)) {
throw error
}
const { port: retryPort } = await getAvailableServerPort(selectedPort + 1, "127.0.0.1")
console.log(pc.dim("Retrying server start on port"), pc.cyan(retryPort.toString()))
return await startServer({ signal, port: retryPort })
}
}