diff --git a/python_rpc/main.py b/python_rpc/main.py index 295d4eff..aa4aca9f 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -1,10 +1,36 @@ from flask import Flask, request, jsonify -import sys, json, urllib.parse, psutil +import sys, json, urllib.parse, psutil, socket from torrent_downloader import TorrentDownloader from http_downloader import HttpDownloader from profile_image_processor import ProfileImageProcessor import libtorrent as lt +RPC_PORT_MIN = 8080 +RPC_PORT_MAX = 9000 + +def find_available_port(preferred_port, start=RPC_PORT_MIN, end=RPC_PORT_MAX): + """Find an available port, trying the preferred port first.""" + # Try preferred port first + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('0.0.0.0', preferred_port)) + return preferred_port + except OSError: + pass + + # Try ports in range + for port in range(start, end + 1): + if port == preferred_port: + continue + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('0.0.0.0', port)) + return port + except OSError: + continue + + raise RuntimeError(f"No available ports in range {start}-{end}") + app = Flask(__name__) # Retrieve command line arguments @@ -192,4 +218,7 @@ def action(): return "", 200 if __name__ == "__main__": - app.run(host="0.0.0.0", port=int(http_port)) + actual_port = find_available_port(int(http_port)) + # Print port for Node.js to capture - must be flushed immediately + print(f"RPC_PORT:{actual_port}", flush=True) + app.run(host="0.0.0.0", port=actual_port) diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index d04b00ab..b8535e35 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -27,11 +27,19 @@ const binaryNameByPlatform: Partial> = { win32: "hydra-python-rpc.exe", }; +const RPC_PORT_PREFIX = "RPC_PORT:"; +const PORT_DISCOVERY_TIMEOUT_MS = 30000; +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 DEFAULT_RPC_PORT = "8084"; + + private static currentPort: string = this.DEFAULT_RPC_PORT; + public static readonly rpc = axios.create({ - baseURL: `http://localhost:${this.RPC_PORT}`, + baseURL: `http://localhost:${this.DEFAULT_RPC_PORT}`, httpAgent: new http.Agent({ family: 4, // Force IPv4 }), @@ -62,6 +70,102 @@ export class PythonRPC { return newPassword; } + private static updateBaseURL(port: string) { + this.currentPort = port; + this.rpc.defaults.baseURL = `http://localhost:${port}`; + pythonRpcLogger.log(`RPC baseURL updated to port ${port}`); + } + + private static parsePortFromStdout(data: string): string | null { + const lines = data.split("\n"); + for (const line of lines) { + if (line.startsWith(RPC_PORT_PREFIX)) { + return line.slice(RPC_PORT_PREFIX.length).trim(); + } + } + return null; + } + + 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"); + } + + private static waitForPort( + childProcess: cp.ChildProcess + ): Promise { + return new Promise((resolve, reject) => { + let resolved = false; + let stdoutBuffer = ""; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject( + new Error( + `Port discovery timed out after ${PORT_DISCOVERY_TIMEOUT_MS}ms` + ) + ); + } + }, PORT_DISCOVERY_TIMEOUT_MS); + + const cleanup = () => { + clearTimeout(timeout); + }; + + if (childProcess.stdout) { + childProcess.stdout.setEncoding("utf-8"); + childProcess.stdout.on("data", (data: string) => { + stdoutBuffer += data; + pythonRpcLogger.log(data); + + const port = this.parsePortFromStdout(stdoutBuffer); + if (port && !resolved) { + resolved = true; + cleanup(); + resolve(port); + } + }); + } + + childProcess.on("error", (err) => { + if (!resolved) { + resolved = true; + cleanup(); + reject(err); + } + }); + + childProcess.on("exit", (code) => { + if (!resolved) { + resolved = true; + cleanup(); + if (code !== 0) { + reject(new Error(`Python RPC process exited with code ${code}`)); + } else { + resolve(null); + } + } + }); + }); + } + public static async spawn( initialDownload?: GamePayload, initialSeeding?: GamePayload[] @@ -70,12 +174,14 @@ export class PythonRPC { const commonArgs = [ this.BITTORRENT_PORT, - this.RPC_PORT, + this.DEFAULT_RPC_PORT, rpcPassword, initialDownload ? JSON.stringify(initialDownload) : "", initialSeeding ? JSON.stringify(initialSeeding) : "", ]; + let childProcess: cp.ChildProcess; + if (app.isPackaged) { const binaryName = binaryNameByPlatform[process.platform]!; const binaryPath = path.join( @@ -91,16 +197,13 @@ export class PythonRPC { ); app.quit(); + return; } - const childProcess = cp.spawn(binaryPath, commonArgs, { + childProcess = cp.spawn(binaryPath, commonArgs, { windowsHide: true, - stdio: ["inherit", "inherit"], + stdio: ["inherit", "pipe", "pipe"], }); - - this.logStderr(childProcess.stderr); - - this.pythonProcess = childProcess; } else { const scriptPath = path.join( __dirname, @@ -110,16 +213,44 @@ export class PythonRPC { "main.py" ); - const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], { - stdio: ["inherit", "inherit"], + childProcess = cp.spawn("python", [scriptPath, ...commonArgs], { + stdio: ["inherit", "pipe", "pipe"], }); - - this.logStderr(childProcess.stderr); - - this.pythonProcess = childProcess; } - this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword; + this.logStderr(childProcess.stderr); + this.pythonProcess = childProcess; + + try { + const port = await this.waitForPort(childProcess); + + if (port) { + this.updateBaseURL(port); + } else { + pythonRpcLogger.log( + `No port received, using default port ${this.DEFAULT_RPC_PORT}` + ); + this.updateBaseURL(this.DEFAULT_RPC_PORT); + } + + this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword; + + await this.waitForHealthCheck(); + + pythonRpcLogger.log( + `Python RPC started successfully on port ${this.currentPort}` + ); + } catch (err) { + pythonRpcLogger.log(`Failed to start Python RPC: ${err}`); + + dialog.showErrorBox( + "RPC Error", + `Failed to start download service. ${err instanceof Error ? err.message : String(err)}\n\nPlease ensure no other application is using ports 8080-9000 and try restarting Hydra.` + ); + + this.kill(); + throw err; + } } public static kill() {