diff --git a/package.json b/package.json index c3dd7ff5..31784bd8 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "embla-carousel-react": "^8.6.0", "file-type": "^20.5.0", "framer-motion": "^12.15.0", + "get-port": "^7.1.0", "hls.js": "^1.5.12", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index d04b00ab..6d89fac8 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -1,5 +1,6 @@ import axios from "axios"; import http from "node:http"; +import getPort, { portNumbers } from "get-port"; import cp from "node:child_process"; import fs from "node:fs"; @@ -27,11 +28,17 @@ const binaryNameByPlatform: Partial> = { win32: "hydra-python-rpc.exe", }; +const RPC_PORT_RANGE_START = 8080; +const RPC_PORT_RANGE_END = 9000; +const DEFAULT_RPC_PORT = 8084; +const HEALTH_CHECK_INTERVAL_MS = 100; +const HEALTH_CHECK_TIMEOUT_MS = 10000; + export class PythonRPC { public static readonly BITTORRENT_PORT = "5881"; - public static readonly RPC_PORT = "8084"; + public static readonly rpc = axios.create({ - baseURL: `http://localhost:${this.RPC_PORT}`, + baseURL: `http://localhost:${DEFAULT_RPC_PORT}`, httpAgent: new http.Agent({ family: 4, // Force IPv4 }), @@ -62,15 +69,46 @@ export class PythonRPC { return newPassword; } + private static async waitForHealthCheck(): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) { + try { + const response = await this.rpc.get("/healthcheck", { timeout: 1000 }); + if (response.status === 200) { + pythonRpcLogger.log("RPC health check passed"); + return; + } + } catch { + // Server not ready yet, continue polling + } + await new Promise((resolve) => + setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS) + ); + } + + throw new Error("RPC health check timed out"); + } + public static async spawn( initialDownload?: GamePayload, initialSeeding?: GamePayload[] ) { const rpcPassword = await this.getRPCPassword(); + const port = await getPort({ + port: [ + DEFAULT_RPC_PORT, + ...portNumbers(RPC_PORT_RANGE_START, RPC_PORT_RANGE_END), + ], + }); + + this.rpc.defaults.baseURL = `http://localhost:${port}`; + pythonRpcLogger.log(`Using RPC port: ${port}`); + const commonArgs = [ this.BITTORRENT_PORT, - this.RPC_PORT, + String(port), rpcPassword, initialDownload ? JSON.stringify(initialDownload) : "", initialSeeding ? JSON.stringify(initialSeeding) : "", @@ -91,6 +129,7 @@ export class PythonRPC { ); app.quit(); + return; } const childProcess = cp.spawn(binaryPath, commonArgs, { @@ -99,7 +138,6 @@ export class PythonRPC { }); this.logStderr(childProcess.stderr); - this.pythonProcess = childProcess; } else { const scriptPath = path.join( @@ -115,11 +153,23 @@ export class PythonRPC { }); this.logStderr(childProcess.stderr); - this.pythonProcess = childProcess; } this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword; + + try { + await this.waitForHealthCheck(); + pythonRpcLogger.log(`Python RPC started successfully on port ${port}`); + } catch (err) { + pythonRpcLogger.log(`Failed to start Python RPC: ${err}`); + dialog.showErrorBox( + "RPC Error", + `Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.` + ); + this.kill(); + throw err; + } } public static kill() { diff --git a/yarn.lock b/yarn.lock index 4cc2c09e..a247d7ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5587,6 +5587,11 @@ get-nonce@^1.0.0: resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== +get-port@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.1.0.tgz#d5a500ebfc7aa705294ec2b83cc38c5d0e364fec" + integrity sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw== + get-proto@^1.0.0, get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"