From 569ad1c862722dca3bd9475bac0434b3bc983481 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 21 Jan 2026 11:28:14 +0200 Subject: [PATCH] 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"