diff --git a/sdk/packages/core/src/hub/daemon/index.test.ts b/sdk/packages/core/src/hub/daemon/index.test.ts index e599b4ea7f..d1e77ab572 100644 --- a/sdk/packages/core/src/hub/daemon/index.test.ts +++ b/sdk/packages/core/src/hub/daemon/index.test.ts @@ -101,16 +101,21 @@ describe("ensureDetachedHubServer", () => { } }); - it("starts the daemon on the resolved default hub port", async () => { + it("lets the daemon bind port 0 when the default endpoint is occupied", async () => { readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ - url: "ws://127.0.0.1:25463/hub", + url: "ws://127.0.0.1:5555/hub", buildId: "current-build", authToken: "new-token", }); - probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ - url: "ws://127.0.0.1:25463/hub", - buildId: "current-build", - }); + probeHubServer + .mockResolvedValueOnce({ + url: "ws://127.0.0.1:25463/hub", + buildId: "current-build", + }) + .mockResolvedValueOnce({ + url: "ws://127.0.0.1:5555/hub", + buildId: "current-build", + }); verifyHubConnection.mockResolvedValueOnce(true); const { ensureDetachedHubServer } = await import("."); @@ -123,15 +128,42 @@ describe("ensureDetachedHubServer", () => { | undefined; expect(result).toEqual({ - url: "ws://127.0.0.1:25463/hub", + url: "ws://127.0.0.1:5555/hub", authToken: "new-token", }); expect(spawn).toHaveBeenCalledOnce(); expect(spawnArgs).toContain("--port"); - expect(spawnArgs).toContain("25463"); + expect(spawnArgs).toContain("0"); expect(spawnOptions?.env?.[CLINE_RUN_AS_HUB_DAEMON_ENV]).toBe("1"); }); + it("keeps an explicit detached hub port fixed", async () => { + readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ + url: "ws://127.0.0.1:31000/hub", + buildId: "current-build", + authToken: "new-token", + }); + probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ + url: "ws://127.0.0.1:31000/hub", + buildId: "current-build", + }); + verifyHubConnection.mockResolvedValueOnce(true); + + const { ensureDetachedHubServer } = await import("."); + const result = await ensureDetachedHubServer("/workspace", { port: 31000 }); + const spawnCalls = (spawn as unknown as { mock: { calls: unknown[][] } }) + .mock.calls; + const spawnArgs = spawnCalls[0]?.[1] as string[] | undefined; + + expect(result).toEqual({ + url: "ws://127.0.0.1:31000/hub", + authToken: "new-token", + }); + expect(spawn).toHaveBeenCalledOnce(); + expect(spawnArgs).toContain("--port"); + expect(spawnArgs).toContain("31000"); + }); + it("retries a transient ETXTBSY spawn failure while starting the detached daemon", async () => { vi.useFakeTimers(); try { diff --git a/sdk/packages/core/src/hub/daemon/index.ts b/sdk/packages/core/src/hub/daemon/index.ts index 8cae9e7822..456e6d6167 100644 --- a/sdk/packages/core/src/hub/daemon/index.ts +++ b/sdk/packages/core/src/hub/daemon/index.ts @@ -206,6 +206,8 @@ export function prewarmDetachedHubServer( return; } const owner = resolveSharedHubOwnerContext(); + const hasExplicitPort = + endpoint.port !== undefined || !!process.env.CLINE_HUB_PORT?.trim(); const resolvedEndpoint = resolveHubEndpointOptions(endpoint); const expectedUrl = createHubServerUrl( resolvedEndpoint.host, @@ -238,7 +240,12 @@ export function prewarmDetachedHubServer( if (expected?.url) { await retireIncompatibleHub(expected, owner.discoveryPath); } - await spawnDetachedHubServerWithRetry(workspaceRoot, resolvedEndpoint); + const shouldUseFallbackPort = + !hasExplicitPort && resolvedEndpoint.port !== 0; + const spawnEndpoint = shouldUseFallbackPort + ? { ...resolvedEndpoint, port: 0 } + : resolvedEndpoint; + await spawnDetachedHubServerWithRetry(workspaceRoot, spawnEndpoint); }) .catch(() => { // best-effort prewarm only @@ -260,6 +267,9 @@ export async function ensureDetachedHubServer( endpointOverrides.port !== undefined || endpointOverrides.pathname !== undefined || !!process.env.CLINE_HUB_PORT?.trim(); + const hasExplicitPort = + endpointOverrides.port !== undefined || + !!process.env.CLINE_HUB_PORT?.trim(); const endpoint = resolveHubEndpointOptions(endpointOverrides); const expectedUrl = createHubServerUrl( endpoint.host, @@ -302,7 +312,11 @@ export async function ensureDetachedHubServer( if (expected?.url) { await retireIncompatibleHub(expected, owner.discoveryPath); } - await spawnDetachedHubServerWithRetry(workspaceRoot, endpoint); + const shouldUseFallbackPort = !hasExplicitPort && endpoint.port !== 0; + const spawnEndpoint = shouldUseFallbackPort + ? { ...endpoint, port: 0 } + : endpoint; + await spawnDetachedHubServerWithRetry(workspaceRoot, spawnEndpoint); const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS; while (Date.now() < deadline) { const nextDiscovery = await readHubDiscovery(owner.discoveryPath);