// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { getBackendPath, getBinaryPath, getCachePath, getVenvPath, getUvEnv, isBinaryExists, runInstallScript, killProcessByName, getPrebuiltPythonDir, getPrebuiltVenvPath } from "./utils/process"; import { spawn, exec } from 'child_process' import log from 'electron-log' import fs from 'fs' import path from 'path' import * as net from "net"; import * as http from "http"; import { ipcMain, BrowserWindow, app } from 'electron' import { promisify } from 'util' import { PromiseReturnType } from "./install-deps"; const execAsync = promisify(exec); // helper function to get main window export function getMainWindow(): BrowserWindow | null { const windows = BrowserWindow.getAllWindows(); return windows.length > 0 ? windows[0] : null; } export async function checkToolInstalled() { return new Promise(async (resolve, reject) => { if (!(await isBinaryExists('uv'))) { resolve({ success: false, message: "uv doesn't exist" }) return } if (!(await isBinaryExists('bun'))) { resolve({ success: false, message: "Bun doesn't exist" }) return } resolve({ success: true, message: "Tools exist already" }) }) } // export async function installDependencies() { // return new Promise(async (resolve, reject) => { // console.log('start install dependencies') // // notify frontend start install // const mainWindow = getMainWindow(); // if (mainWindow && !mainWindow.isDestroyed()) { // mainWindow.webContents.send('install-dependencies-start'); // } // const isInstalCommandTool = await installCommandTool() // if (!isInstalCommandTool) { // resolve(false) // return // } // const uv_path = await getBinaryPath('uv') // const backendPath = getBackendPath() // // ensure backend directory exists and is writable // if (!fs.existsSync(backendPath)) { // fs.mkdirSync(backendPath, { recursive: true }) // } // // touch installing lock file // const installingLockPath = path.join(backendPath, 'uv_installing.lock') // fs.writeFileSync(installingLockPath, '') // const proxy = ['--default-index', 'https://pypi.tuna.tsinghua.edu.cn/simple'] // function isInChinaTimezone() { // const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // return timezone === 'Asia/Shanghai'; // } // console.log('isInChinaTimezone', isInChinaTimezone()) // const node_process = spawn(uv_path, ['sync', '--no-dev', ...(isInChinaTimezone() ? proxy : [])], { cwd: backendPath }) // node_process.stdout.on('data', (data) => { // log.info(`Script output: ${data}`) // // notify frontend install log // const mainWindow = getMainWindow(); // if (mainWindow && !mainWindow.isDestroyed()) { // mainWindow.webContents.send('install-dependencies-log', { type: 'stdout', data: data.toString() }); // } // }) // node_process.stderr.on('data', (data) => { // log.error(`Script error: uv ${data}`) // // notify frontend install error log // const mainWindow = getMainWindow(); // if (mainWindow && !mainWindow.isDestroyed()) { // mainWindow.webContents.send('install-dependencies-log', { type: 'stderr', data: data.toString() }); // } // }) // node_process.on('close', async (code) => { // // delete installing lock file // if (fs.existsSync(installingLockPath)) { // fs.unlinkSync(installingLockPath) // } // if (code === 0) { // log.info('Script completed successfully') // // touch installed lock file // const installedLockPath = path.join(backendPath, 'uv_installed.lock') // fs.writeFileSync(installedLockPath, '') // console.log('end install dependencies') // spawn(uv_path, ['run', 'task', 'babel'], { cwd: backendPath }) // resolve(true); // // resolve(isSuccess); // } else { // log.error(`Script exited with code ${code}`) // // notify frontend install failed // const mainWindow = getMainWindow(); // if (mainWindow && !mainWindow.isDestroyed()) { // mainWindow.webContents.send('install-dependencies-complete', { success: false, code, error: `Script exited with code ${code}` }); // resolve(false); // } // } // }) // }) // } export async function startBackend(setPort?: (port: number) => void): Promise { console.log('start fastapi') const uv_path = await getBinaryPath('uv') const backendPath = getBackendPath() const userData = app.getPath('userData'); const currentVersion = app.getVersion(); const venvPath = getVenvPath(currentVersion); console.log('userData', userData) console.log('Using venv path:', venvPath) // Try to find an available port, with aggressive cleanup if needed let port: number; const portFile = path.join(userData, 'port.txt'); if (fs.existsSync(portFile)) { port = parseInt(fs.readFileSync(portFile, 'utf-8')); log.info(`Found port from file: ${port}`); await killProcessOnPort(port); } try { port = await findAvailablePort(5001); fs.writeFileSync(portFile, port.toString()); log.info(`Found available port: ${port}`); } catch (error) { log.error('Failed to find available port, attempting cleanup...'); // Last resort: try to kill all processes in the range for (let p = 5001; p <= 5050; p++) { await killProcessOnPort(p); } // Try once more port = await findAvailablePort(5001); } if (setPort) { setPort(port); } const npmCacheDir = path.join(venvPath, '.npm-cache'); if (!fs.existsSync(npmCacheDir)) { fs.mkdirSync(npmCacheDir, { recursive: true }); } const uvEnv = getUvEnv(currentVersion); const env = { ...process.env, ...uvEnv, SERVER_URL: "https://dev.eigent.ai/api", PYTHONIOENCODING: 'utf-8', PYTHONUNBUFFERED: '1', npm_config_cache: npmCacheDir, } const displayFilteredLogs = (data: String) => { if (!data) return; const msg = data.toString().trimEnd(); if (msg.toLowerCase().includes("error") || msg.toLowerCase().includes("traceback")) { log.error(`BACKEND: ${msg}`); } else if (msg.toLowerCase().includes("warn")) { // Skip warnings } else if (msg.includes("DEBUG")) { log.debug(`BACKEND: ${msg}`); } else { log.info(`BACKEND: ${msg}`); } } return new Promise(async (resolve, reject) => { log.info(`Spawning backend process: ${uv_path} run uvicorn main:api --port ${port} --loop asyncio`); log.info(`Backend working directory: ${backendPath}`); log.info(`Using venv: ${venvPath}`); try { const { stdout: uvVersion } = await execAsync(`${uv_path} --version`); log.info(`UV version check: ${uvVersion.trim()}`); const { stdout: pythonTest } = await execAsync( `${uv_path} run python -c "print('Python OK')"`, { cwd: backendPath, env: env } ); log.info(`Python test output: ${pythonTest.trim()}`); } catch (testErr: any) { log.warn(`Pre-flight check failed, attempting repair: ${testErr}`); try { // Attempt to repair the environment log.info("Attempting to repair environment..."); // Cleanup stale processes and locks log.info("Cleaning up stale processes and locks..."); await killProcessByName('uv'); await killProcessByName('python'); // Try to remove the lock file explicitly if it exists try { const lockFile = path.join(getCachePath('uv_python'), '.lock'); if (fs.existsSync(lockFile)) { fs.unlinkSync(lockFile); } } catch (e) { log.warn(`Failed to remove lock file: ${e}`); } // Cleanup corrupted python cache ONLY if it's not the bundled Python // If we have bundled Python, we want to keep it and reuse it const prebuiltPythonDir = getPrebuiltPythonDir(); try { const pythonCacheDir = getCachePath('uv_python'); // Only remove if it's NOT the prebuilt Python directory if (fs.existsSync(pythonCacheDir) && pythonCacheDir !== prebuiltPythonDir) { log.info(`Removing potentially corrupted Python cache: ${pythonCacheDir}`); fs.rmSync(pythonCacheDir, { recursive: true, force: true }); } else if (prebuiltPythonDir) { log.info(`Preserving bundled Python at: ${prebuiltPythonDir}`); } } catch (e) { log.warn(`Failed to remove Python cache: ${e}`); } // Cleanup corrupted venv (pyvenv.cfg may reference non-existent Python version) // This is especially important for prebuilt venvs with hardcoded paths from CI const prebuiltVenvPath = getPrebuiltVenvPath(); try { // If the broken venv is the prebuilt venv, we need to remove it // and let UV recreate it from the bundled Python if (fs.existsSync(venvPath)) { log.info(`Removing potentially corrupted venv: ${venvPath}`); if (venvPath === prebuiltVenvPath) { log.info(`This is the prebuilt venv with hardcoded paths - will recreate from bundled Python`); } fs.rmSync(venvPath, { recursive: true, force: true }); } } catch (e) { log.warn(`Failed to remove venv: ${e}`); } // Use proxy if in China (simple check based on timezone) // Add official PyPI as fallback for packages not available on mirror const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const proxyArgs = timezone === 'Asia/Shanghai' ? [ '--default-index', 'https://mirrors.aliyun.com/pypi/simple/', '--index', 'https://pypi.org/simple/' ] : []; // Step 1: Ensure Python is installed (fixes corrupted/missing Python) log.info("Step 1: Ensuring Python is installed..."); await execAsync(`${uv_path} python install 3.10`, { cwd: backendPath, env: env }); // Step 2: Sync dependencies log.info("Step 2: Syncing dependencies..."); const syncArgs = ['sync', '--no-dev', ...proxyArgs]; await execAsync(`${uv_path} ${syncArgs.join(' ')}`, { cwd: backendPath, env: env }); // Retry the check const { stdout: pythonTest } = await execAsync( `${uv_path} run python -c "print('Python OK')"`, { cwd: backendPath, env: env } ); log.info(`Python test output after repair: ${pythonTest.trim()}`); } catch (repairErr) { log.error(`Repair failed: ${repairErr}`); reject(new Error(`Backend environment check failed: ${testErr}\nRepair failed: ${repairErr}`)); return; } } const node_process = spawn( uv_path, ["run", "uvicorn", "main:api", "--port", port.toString(), "--loop", "asyncio"], { cwd: backendPath, env: env, detached: process.platform !== 'win32', stdio: ['ignore', 'ignore', 'pipe'] // stdin=ignore, stdout=ignore, stderr=pipe (Python logs to stderr) } ); // NOTE: Do NOT use unref() - we need to maintain the process reference // to properly capture stdout/stderr and manage the process lifecycle log.info(`Backend process spawned with PID: ${node_process.pid}`); setTimeout(() => { if (node_process.killed) { log.error('Backend process was killed immediately after spawn'); } else if (!node_process.pid) { log.error('Backend process has no PID'); } else { log.info(`Backend process still running after 1s with PID ${node_process.pid}`); } }, 1000); let started = false; let healthCheckInterval: NodeJS.Timeout | null = null; const startTimeout = setTimeout(() => { if (!started) { if (healthCheckInterval) clearInterval(healthCheckInterval); killBackendProcess(node_process); reject(new Error('Backend failed to start within timeout')); } }, 65000); const initialDelay = setTimeout(() => { if (!started) { log.info('Starting backend health check polling...'); pollHealthEndpoint(); } }, 2000); const killBackendProcess = (proc: any) => { if (!proc || !proc.pid) return; log.info(`Killing backend process ${proc.pid} and its children...`); try { if (process.platform === 'win32') { spawn('taskkill', ['/pid', proc.pid.toString(), '/T', '/F']); } else { try { process.kill(-proc.pid, 'SIGTERM'); setTimeout(() => { try { process.kill(-proc.pid, 'SIGKILL'); } catch (e) { } }, 1000); } catch (e) { log.error(`Failed to kill process group: ${e}`); proc.kill('SIGKILL'); } } } catch (e) { log.error(`Failed to kill backend process: ${e}`); } }; const pollHealthEndpoint = (): void => { let attempts = 0; const maxAttempts = 240; const intervalMs = 250; healthCheckInterval = setInterval(() => { attempts++; const healthUrl = `http://127.0.0.1:${port}/health`; log.debug(`Health check attempt ${attempts}/${maxAttempts}: ${healthUrl}`); const req = http.get(healthUrl, { timeout: 1000 }, (res) => { if (res.statusCode === 200) { log.info(`Backend health check passed after ${attempts} attempts`); started = true; clearTimeout(startTimeout); if (healthCheckInterval) clearInterval(healthCheckInterval); resolve(node_process); } else { // Non-200 status (e.g., 404), continue polling unless max attempts reached if (attempts >= maxAttempts) { log.error(`Backend health check failed after ${attempts} attempts with status ${res.statusCode}`); started = true; clearTimeout(startTimeout); if (healthCheckInterval) clearInterval(healthCheckInterval); killBackendProcess(node_process); reject(new Error(`Backend health check failed: HTTP ${res.statusCode}`)); } } }); req.on('error', () => { // Connection error - backend might not be ready yet, continue polling if (attempts >= maxAttempts) { log.error(`Backend health check failed after ${attempts} attempts: unable to connect`); started = true; clearTimeout(startTimeout); if (healthCheckInterval) clearInterval(healthCheckInterval); killBackendProcess(node_process); reject(new Error('Backend health check failed: unable to connect')); } }); req.on('timeout', () => { req.destroy(); if (attempts >= maxAttempts) { log.error(`Backend health check timed out after ${attempts} attempts`); started = true; clearTimeout(startTimeout); if (healthCheckInterval) clearInterval(healthCheckInterval); killBackendProcess(node_process); reject(new Error('Backend health check timed out')); } }); }, intervalMs); }; node_process.stderr.on('data', (data) => { displayFilteredLogs(data); if (data.toString().includes("Address already in use") || data.toString().includes("bind() failed")) { if (!started) { started = true; clearTimeout(startTimeout); clearTimeout(initialDelay); if (healthCheckInterval) clearInterval(healthCheckInterval); killBackendProcess(node_process); reject(new Error(`Port ${port} is already in use`)); } } }); node_process.on('error', (err) => { log.error(`Backend process error: ${err.message}`); if (!started) { started = true; clearTimeout(startTimeout); clearTimeout(initialDelay); if (healthCheckInterval) clearInterval(healthCheckInterval); reject(new Error(`Failed to spawn backend process: ${err.message}`)); } }); node_process.on('close', async (code, signal) => { log.info(`Backend process closed with code ${code}, signal ${signal}`); clearTimeout(startTimeout); clearTimeout(initialDelay); if (healthCheckInterval) clearInterval(healthCheckInterval); if (!started) { log.info(`Backend exited before ready, cleaning up port ${port}...`); await killProcessOnPort(port); reject(new Error(`Backend exited prematurely with code ${code}`)); } }); }); // const node_process = spawn( // uv_path, // ["run", "uvicorn", "main:api", "--port", port.toString(), "--loop", "asyncio"], // { cwd: backendPath, env: env, detached: false } // ); // node_process.stdout.on('data', (data) => { // log.info(`fastapi output: ${data}`) // }) // node_process.stderr.on('data', (data) => { // log.error(`fastapi stderr output: ${data}`) // }) // node_process.on('close', (code) => { // if (code === 0) { // log.info('fastapi start success') // } else { // log.error(`fastapi exited with code ${code}`) // } // }) // return node_process } function checkPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); // Set a timeout to prevent hanging const timeout = setTimeout(() => { server.close(); resolve(false); }, 1000); server.once('error', (err: any) => { clearTimeout(timeout); if (err.code === 'EADDRINUSE') { // Try to connect to the port to verify it's truly in use const client = new net.Socket(); client.setTimeout(500); client.once('connect', () => { client.destroy(); resolve(false); // Port is definitely in use }); client.once('error', () => { client.destroy(); // Port might be in a weird state, consider it unavailable resolve(false); }); client.once('timeout', () => { client.destroy(); resolve(false); }); client.connect(port, '127.0.0.1'); } else { resolve(false); } }); server.once('listening', () => { clearTimeout(timeout); server.close(() => { console.log('try port', port) resolve(true) }); // port available, close then return }); // force listen all addresses, prevent judgment server.listen({ port, host: "127.0.0.1", exclusive: true }); }); } export async function killProcessOnPort(port: number): Promise { try { const platform = process.platform; if (platform === 'win32') { // 1. get pid of process listen on port const { stdout: netstatOut } = await execAsync(`netstat -ano | findstr LISTENING | findstr :${port}`); const lines = netstatOut.trim().split(/\r?\n/).filter(Boolean); if (lines.length === 0) { console.log(`no process listen on port ${port}`); return true; } // get pid from last field const pid = lines[0].trim().split(/\s+/).pop(); if (!pid || isNaN(Number(pid))) { console.log(`Invalid PID extracted for port ${port}: ${pid}`); return false; } console.log(`Killing PID: ${pid}`); await execAsync(`taskkill /F /PID ${pid}`); } else if (platform === 'darwin') { await execAsync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`); } else { await execAsync(`fuser -k ${port}/tcp 2>/dev/null || true`); } // Wait a bit for the process to be killed await new Promise(resolve => setTimeout(resolve, 500)); // Check if port is now available return await checkPortAvailable(port); } catch (error) { log.error(`Failed to kill process on port ${port}:`, error); return false; } } export async function findAvailablePort(startPort: number, maxAttempts = 50): Promise { const triedPorts = new Set(); const tryPort = async (port: number): Promise => { if (triedPorts.has(port)) return null; triedPorts.add(port); const available = await checkPortAvailable(port); if (available) { return port; } const killed = await killProcessOnPort(port); if (killed) { return port; } return null; }; // return when found port for (let offset = 0; offset < maxAttempts; offset++) { const port = startPort + offset; const found = await tryPort(port); if (found) return found; } throw new Error(`No available port found in range ${startPort} ~ ${startPort + maxAttempts - 1}`); }