From aae35b591d0c356d82ac64cf59041c2e4156da54 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 20 Jan 2026 19:25:32 +0200 Subject: [PATCH 1/3] feat: implement dynamic port discovery for Python RPC service --- python_rpc/main.py | 33 ++++++- src/main/services/python-rpc.ts | 163 ++++++++++++++++++++++++++++---- 2 files changed, 178 insertions(+), 18 deletions(-) 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() { From 46154fa49aaf97094bff15d432a14c399582f8f9 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 20 Jan 2026 19:34:04 +0200 Subject: [PATCH 2/3] fix: correct error handling in Python RPC process exit code --- src/main/services/python-rpc.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index b8535e35..38966e65 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -156,10 +156,10 @@ export class PythonRPC { if (!resolved) { resolved = true; cleanup(); - if (code !== 0) { - reject(new Error(`Python RPC process exited with code ${code}`)); - } else { + if (code === 0) { resolve(null); + } else { + reject(new Error(`Python RPC process exited with code ${code}`)); } } }); From 569ad1c862722dca3bd9475bac0434b3bc983481 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 21 Jan 2026 11:28:14 +0200 Subject: [PATCH 3/3] chore: add get-port dependency and refactor Python RPC port handling --- package.json | 1 + python_rpc/main.py | 33 +------- src/main/services/python-rpc.ts | 139 +++++++------------------------- yarn.lock | 5 ++ 4 files changed, 37 insertions(+), 141 deletions(-) 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/python_rpc/main.py b/python_rpc/main.py index aa4aca9f..295d4eff 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -1,36 +1,10 @@ from flask import Flask, request, jsonify -import sys, json, urllib.parse, psutil, socket +import sys, json, urllib.parse, psutil 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 @@ -218,7 +192,4 @@ def action(): return "", 200 if __name__ == "__main__": - 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) + app.run(host="0.0.0.0", port=int(http_port)) diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 38966e65..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,19 +28,17 @@ const binaryNameByPlatform: Partial> = { win32: "hydra-python-rpc.exe", }; -const RPC_PORT_PREFIX = "RPC_PORT:"; -const PORT_DISCOVERY_TIMEOUT_MS = 30000; +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 DEFAULT_RPC_PORT = "8084"; - - private static currentPort: string = this.DEFAULT_RPC_PORT; public static readonly rpc = axios.create({ - baseURL: `http://localhost:${this.DEFAULT_RPC_PORT}`, + baseURL: `http://localhost:${DEFAULT_RPC_PORT}`, httpAgent: new http.Agent({ family: 4, // Force IPv4 }), @@ -70,22 +69,6 @@ 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(); @@ -107,81 +90,30 @@ export class PythonRPC { 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) { - resolve(null); - } else { - reject(new Error(`Python RPC process exited with code ${code}`)); - } - } - }); - }); - } - 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.DEFAULT_RPC_PORT, + String(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( @@ -200,10 +132,13 @@ export class PythonRPC { return; } - childProcess = cp.spawn(binaryPath, commonArgs, { + const childProcess = cp.spawn(binaryPath, commonArgs, { windowsHide: true, - stdio: ["inherit", "pipe", "pipe"], + stdio: ["inherit", "inherit"], }); + + this.logStderr(childProcess.stderr); + this.pythonProcess = childProcess; } else { const scriptPath = path.join( __dirname, @@ -213,41 +148,25 @@ export class PythonRPC { "main.py" ); - childProcess = cp.spawn("python", [scriptPath, ...commonArgs], { - stdio: ["inherit", "pipe", "pipe"], + const childProcess = cp.spawn("python", [scriptPath, ...commonArgs], { + stdio: ["inherit", "inherit"], }); + + this.logStderr(childProcess.stderr); + this.pythonProcess = childProcess; } - this.logStderr(childProcess.stderr); - this.pythonProcess = childProcess; + this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword; 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}` - ); + 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. ${err instanceof Error ? err.message : String(err)}\n\nPlease ensure no other application is using ports 8080-9000 and try restarting Hydra.` + `Failed to start download service.\n\nThe service did not respond in time. Please try restarting Hydra.` ); - this.kill(); throw err; } 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"