diff --git a/src/cli/run/server-connection.test.ts b/src/cli/run/server-connection.test.ts index ad5e1aa9..a9e22412 100644 --- a/src/cli/run/server-connection.test.ts +++ b/src/cli/run/server-connection.test.ts @@ -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 diff --git a/src/cli/run/server-connection.ts b/src/cli/run/server-connection.ts index 934d469d..3472e7a2 100644 --- a/src/cli/run/server-connection.ts +++ b/src/cli/run/server-connection.ts @@ -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 { + 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 }) + } }