From 019d6dd7bd8c6fe6609a635d40dbb902a3317127 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 22:50:42 -0500 Subject: [PATCH] fix memory leak --- TODO.md | 17 ++ apps/ui/src/app.tsx | 13 +- package.json | 1 + start.mjs | 722 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 TODO.md create mode 100755 start.mjs diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..3771806b --- /dev/null +++ b/TODO.md @@ -0,0 +1,17 @@ +# Bugs + +- Setting the default model does not seem like it works. + +# UX + +- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff +- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex. +- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live +- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card. +- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them. +- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time. +- Typing in the text area of the plan mode was super laggy. +- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something. +- modals are not scrollable if height of the screen is small enough +- and the Agent Runner add an archival button for the new sessions. +- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue. diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 50380095..c14ab6d0 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { RouterProvider } from '@tanstack/react-router'; import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; @@ -15,6 +15,17 @@ export default function App() { return true; }); + // Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode + // React's internal scheduler creates performance marks/measures that accumulate without cleanup + useEffect(() => { + const clearPerfEntries = () => { + performance.clearMarks(); + performance.clearMeasures(); + }; + const interval = setInterval(clearPerfEntries, 5000); + return () => clearInterval(interval); + }, []); + // Run settings migration on startup (localStorage -> file storage) const migrationState = useSettingsMigration(); if (migrationState.migrated) { diff --git a/package.json b/package.json index bb9c7efa..e3364964 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", "dev": "node init.mjs", + "start": "node start.mjs", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/start.mjs b/start.mjs new file mode 100755 index 00000000..4d5153fd --- /dev/null +++ b/start.mjs @@ -0,0 +1,722 @@ +#!/usr/bin/env node + +/** + * Automaker - Production Mode Launch Script + * + * This script runs the application in production mode (no dev server). + * It builds everything if needed, then prompts the user to choose web or electron mode. + * + * SECURITY NOTE: This script uses a restricted fs wrapper that only allows + * operations within the script's directory (__dirname). This is a standalone + * launch script that runs before the platform library is available. + */ + +import { execSync } from 'child_process'; +import fsNative from 'fs'; +import http from 'http'; +import path from 'path'; +import readline from 'readline'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const treeKill = require('tree-kill'); +const crossSpawn = require('cross-spawn'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ============================================================================= +// Restricted fs wrapper - only allows operations within __dirname +// ============================================================================= + +/** + * Validate that a path is within the script's directory + * @param {string} targetPath - Path to validate + * @returns {string} - Resolved path if valid + * @throws {Error} - If path is outside __dirname + */ +function validateScriptPath(targetPath) { + const resolved = path.resolve(__dirname, targetPath); + const normalizedBase = path.resolve(__dirname); + if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { + throw new Error( + `[start.mjs] Security: Path access denied outside script directory: ${targetPath}` + ); + } + return resolved; +} + +/** + * Restricted fs operations - only within script directory + */ +const fs = { + existsSync(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.existsSync(validated); + }, + mkdirSync(targetPath, options) { + const validated = validateScriptPath(targetPath); + return fsNative.mkdirSync(validated, options); + }, + createWriteStream(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.createWriteStream(validated); + }, +}; + +// Colors for terminal output (works on modern terminals including Windows) +const colors = { + green: '\x1b[0;32m', + blue: '\x1b[0;34m', + yellow: '\x1b[1;33m', + red: '\x1b[0;31m', + reset: '\x1b[0m', +}; + +const isWindows = process.platform === 'win32'; + +// Track background processes for cleanup +let serverProcess = null; +let webProcess = null; +let electronProcess = null; + +/** + * Print colored output + */ +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +/** + * Print the header banner + */ +function printHeader() { + console.log('╔═══════════════════════════════════════════════════════╗'); + console.log('║ Automaker Production Mode ║'); + console.log('╚═══════════════════════════════════════════════════════╝'); + console.log(''); +} + +/** + * Execute a command synchronously and return stdout + */ +function execCommand(command, options = {}) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: 'pipe', + ...options, + }).trim(); + } catch { + return null; + } +} + +/** + * Get process IDs using a specific port (cross-platform) + */ +function getProcessesOnPort(port) { + const pids = new Set(); + + if (isWindows) { + // Windows: Use netstat to find PIDs + try { + const output = execCommand(`netstat -ano | findstr :${port}`); + if (output) { + const lines = output.split('\n'); + for (const line of lines) { + // Match lines with LISTENING or ESTABLISHED on our port + const match = line.match(/:\d+\s+.*?(\d+)\s*$/); + if (match) { + const pid = parseInt(match[1], 10); + if (pid > 0) pids.add(pid); + } + } + } + } catch { + // Ignore errors + } + } else { + // Unix: Use lsof + try { + const output = execCommand(`lsof -ti:${port}`); + if (output) { + output.split('\n').forEach((pid) => { + const parsed = parseInt(pid.trim(), 10); + if (parsed > 0) pids.add(parsed); + }); + } + } catch { + // Ignore errors + } + } + + return Array.from(pids); +} + +/** + * Kill a process by PID (cross-platform) + */ +function killProcess(pid) { + try { + if (isWindows) { + execCommand(`taskkill /F /PID ${pid}`); + } else { + process.kill(pid, 'SIGKILL'); + } + return true; + } catch { + return false; + } +} + +/** + * Check if a port is in use (without killing) + */ +function isPortInUse(port) { + const pids = getProcessesOnPort(port); + return pids.length > 0; +} + +/** + * Kill processes on a port and wait for it to be freed + */ +async function killPort(port) { + const pids = getProcessesOnPort(port); + + if (pids.length === 0) { + log(`✓ Port ${port} is available`, 'green'); + return true; + } + + log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); + + for (const pid of pids) { + killProcess(pid); + } + + // Wait for port to be freed (max 5 seconds) + for (let i = 0; i < 10; i++) { + await sleep(500); + const remainingPids = getProcessesOnPort(port); + if (remainingPids.length === 0) { + log(`✓ Port ${port} is now free`, 'green'); + return true; + } + } + + log(`Warning: Port ${port} may still be in use`, 'red'); + return false; +} + +/** + * Sleep for a given number of milliseconds + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if the server health endpoint is responding + */ +function checkHealth(port = 3008) { + return new Promise((resolve) => { + const req = http.get(`http://localhost:${port}/api/health`, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.setTimeout(2000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** + * Prompt the user for input + */ +function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +/** + * Run npm command using cross-spawn for Windows compatibility + */ +function runNpm(args, options = {}) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd: __dirname, + ...restOptions, + // Ensure environment variables are properly merged with process.env + env: { + ...process.env, + ...(env || {}), + }, + }; + // cross-spawn handles Windows .cmd files automatically + return crossSpawn('npm', args, spawnOptions); +} + +/** + * Run an npm command and wait for completion + */ +function runNpmAndWait(args, options = {}) { + const child = runNpm(args, options); + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + +/** + * Run npx command using cross-spawn for Windows compatibility + */ +function runNpx(args, options = {}) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd: __dirname, + ...restOptions, + // Ensure environment variables are properly merged with process.env + env: { + ...process.env, + ...(env || {}), + }, + }; + // cross-spawn handles Windows .cmd files automatically + return crossSpawn('npx', args, spawnOptions); +} + +/** + * Kill a process tree using tree-kill + */ +function killProcessTree(pid) { + return new Promise((resolve) => { + if (!pid) { + resolve(); + return; + } + treeKill(pid, 'SIGTERM', (err) => { + if (err) { + // Try force kill if graceful termination fails + treeKill(pid, 'SIGKILL', () => resolve()); + } else { + resolve(); + } + }); + }); +} + +/** + * Cleanup function to kill all spawned processes + */ +async function cleanup() { + console.log('\nCleaning up...'); + + const killPromises = []; + + if (serverProcess && !serverProcess.killed && serverProcess.pid) { + killPromises.push(killProcessTree(serverProcess.pid)); + } + + if (webProcess && !webProcess.killed && webProcess.pid) { + killPromises.push(killProcessTree(webProcess.pid)); + } + + if (electronProcess && !electronProcess.killed && electronProcess.pid) { + killPromises.push(killProcessTree(electronProcess.pid)); + } + + await Promise.all(killPromises); +} + +/** + * Check if production builds exist + */ +function checkBuilds() { + const serverDist = path.join(__dirname, 'apps', 'server', 'dist'); + const uiDist = path.join(__dirname, 'apps', 'ui', 'dist'); + const electronDist = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); + + return { + server: fs.existsSync(serverDist), + ui: fs.existsSync(uiDist), + electron: fs.existsSync(electronDist), + }; +} + +/** + * Main function + */ +async function main() { + // Change to script directory + process.chdir(__dirname); + + printHeader(); + + // Check if node_modules exists + if (!fs.existsSync(path.join(__dirname, 'node_modules'))) { + log('Installing dependencies...', 'blue'); + const install = runNpm(['install'], { stdio: 'inherit' }); + await new Promise((resolve, reject) => { + install.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm install failed with code ${code}`)); + }); + }); + } + + // Always build shared packages first to ensure they're up to date + // (source may have changed even if dist directories exist) + log('Building shared packages...', 'blue'); + try { + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }); + log('✓ Shared packages built', 'green'); + } catch (error) { + log(`Failed to build shared packages: ${error.message}`, 'red'); + process.exit(1); + } + + // Always rebuild server to ensure it's in sync with packages + log('Building server...', 'blue'); + try { + await runNpmAndWait(['run', 'build'], { stdio: 'inherit', cwd: path.join(__dirname, 'apps', 'server') }); + log('✓ Server built', 'green'); + } catch (error) { + log(`Failed to build server: ${error.message}`, 'red'); + process.exit(1); + } + + // Check if UI/Electron builds exist (these are slower, so only build if missing) + const builds = checkBuilds(); + + if (!builds.ui || !builds.electron) { + log('UI/Electron builds not found. Building...', 'yellow'); + console.log(''); + + try { + // Build UI (includes Electron main process) + log('Building UI...', 'blue'); + await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }); + + log('✓ Build complete!', 'green'); + console.log(''); + } catch (error) { + log(`Build failed: ${error.message}`, 'red'); + process.exit(1); + } + } else { + log('✓ UI builds found', 'green'); + console.log(''); + } + + // Check for processes on required ports and prompt user + log('Checking for processes on ports 3007 and 3008...', 'yellow'); + + const webPortInUse = isPortInUse(3007); + const serverPortInUse = isPortInUse(3008); + + let webPort = 3007; + let serverPort = 3008; + let corsOriginEnv = process.env.CORS_ORIGIN || ''; + + if (webPortInUse || serverPortInUse) { + console.log(''); + if (webPortInUse) { + const pids = getProcessesOnPort(3007); + log(`⚠ Port 3007 is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + if (serverPortInUse) { + const pids = getProcessesOnPort(3008); + log(`⚠ Port 3008 is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + console.log(''); + + while (true) { + const choice = await prompt( + 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' + ); + const lowerChoice = choice.toLowerCase(); + + if (lowerChoice === 'k' || lowerChoice === 'kill') { + if (webPortInUse) { + await killPort(3007); + } else { + log(`✓ Port 3007 is available`, 'green'); + } + if (serverPortInUse) { + await killPort(3008); + } else { + log(`✓ Port 3008 is available`, 'green'); + } + break; + } else if (lowerChoice === 'u' || lowerChoice === 'use') { + // Prompt for new ports + while (true) { + const newWebPort = await prompt('Enter web port (default 3007): '); + const parsedWebPort = newWebPort.trim() ? parseInt(newWebPort.trim(), 10) : 3007; + + if (isNaN(parsedWebPort) || parsedWebPort < 1024 || parsedWebPort > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (isPortInUse(parsedWebPort)) { + const pids = getProcessesOnPort(parsedWebPort); + log( + `Port ${parsedWebPort} is already in use by process(es): ${pids.join(', ')}`, + 'red' + ); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + webPort = parsedWebPort; + break; + } + + while (true) { + const newServerPort = await prompt('Enter server port (default 3008): '); + const parsedServerPort = newServerPort.trim() ? parseInt(newServerPort.trim(), 10) : 3008; + + if (isNaN(parsedServerPort) || parsedServerPort < 1024 || parsedServerPort > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (parsedServerPort === webPort) { + log('Server port cannot be the same as web port.', 'red'); + continue; + } + + if (isPortInUse(parsedServerPort)) { + const pids = getProcessesOnPort(parsedServerPort); + log( + `Port ${parsedServerPort} is already in use by process(es): ${pids.join(', ')}`, + 'red' + ); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + serverPort = parsedServerPort; + break; + } + + log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); + break; + } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { + log('Cancelled.', 'yellow'); + process.exit(0); + } else { + log( + 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', + 'red' + ); + } + } + } else { + log(`✓ Port 3007 is available`, 'green'); + log(`✓ Port 3008 is available`, 'green'); + } + + // Ensure backend CORS allows whichever UI port we ended up using. + { + const existing = (process.env.CORS_ORIGIN || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .filter((o) => o !== '*'); + const origins = new Set(existing); + origins.add(`http://localhost:${webPort}`); + origins.add(`http://127.0.0.1:${webPort}`); + corsOriginEnv = Array.from(origins).join(','); + } + console.log(''); + + // Show menu + console.log('═══════════════════════════════════════════════════════'); + console.log(' Select Application Mode:'); + console.log('═══════════════════════════════════════════════════════'); + console.log(' 1) Web Application (Browser)'); + console.log(' 2) Desktop Application (Electron)'); + console.log('═══════════════════════════════════════════════════════'); + console.log(''); + + // Setup cleanup handlers + let cleaningUp = false; + const handleExit = async (signal) => { + if (cleaningUp) return; + cleaningUp = true; + await cleanup(); + process.exit(0); + }; + + process.on('SIGINT', () => handleExit('SIGINT')); + process.on('SIGTERM', () => handleExit('SIGTERM')); + + // Prompt for choice + while (true) { + const choice = await prompt('Enter your choice (1 or 2): '); + + if (choice === '1') { + console.log(''); + log('Launching Web Application (Production Mode)...', 'blue'); + + // Start the backend server in production mode + log(`Starting backend server on port ${serverPort}...`, 'blue'); + + // Create logs directory + if (!fs.existsSync(path.join(__dirname, 'logs'))) { + fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); + } + + // Start server in background, showing output in console AND logging to file + const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); + serverProcess = runNpm(['run', 'start'], { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: path.join(__dirname, 'apps', 'server'), + env: { + PORT: String(serverPort), + CORS_ORIGIN: corsOriginEnv, + }, + }); + + // Pipe to both log file and console + serverProcess.stdout?.on('data', (data) => { + process.stdout.write(data); + logStream.write(data); + }); + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(data); + logStream.write(data); + }); + + log('Waiting for server to be ready...', 'yellow'); + + // Wait for server health check + const maxRetries = 30; + let serverReady = false; + + for (let i = 0; i < maxRetries; i++) { + if (await checkHealth(serverPort)) { + serverReady = true; + break; + } + process.stdout.write('.'); + await sleep(1000); + } + + console.log(''); + + if (!serverReady) { + log('Error: Server failed to start', 'red'); + console.log('Check logs/server.log for details'); + cleanup(); + process.exit(1); + } + + log('✓ Server is ready!', 'green'); + log(`Starting web server...`, 'blue'); + + // Start vite preview to serve built static files + webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { + stdio: 'inherit', + cwd: path.join(__dirname, 'apps', 'ui'), + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, + }); + + log(`The application is available at: http://localhost:${webPort}`, 'green'); + console.log(''); + + await new Promise((resolve) => { + webProcess.on('close', resolve); + }); + + break; + } else if (choice === '2') { + console.log(''); + log('Launching Desktop Application (Production Mode)...', 'blue'); + log('(Electron will start its own backend server)', 'yellow'); + console.log(''); + + // Run electron directly with the built main.js + const electronMainPath = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); + + if (!fs.existsSync(electronMainPath)) { + log('Error: Electron main process not built. Run build first.', 'red'); + process.exit(1); + } + + // Start vite preview to serve built static files for electron + // (Electron in non-packaged mode needs a server to load from) + log('Starting static file server...', 'blue'); + webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: path.join(__dirname, 'apps', 'ui'), + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, + }); + + // Wait a moment for vite preview to start + await sleep(2000); + + // Use electron from node_modules + electronProcess = runNpx(['electron', electronMainPath], { + stdio: 'inherit', + cwd: path.join(__dirname, 'apps', 'ui'), + env: { + TEST_PORT: String(webPort), + PORT: String(serverPort), + VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, + VITE_SERVER_URL: `http://localhost:${serverPort}`, + CORS_ORIGIN: corsOriginEnv, + NODE_ENV: 'production', + }, + }); + + await new Promise((resolve) => { + electronProcess.on('close', () => { + // Also kill vite preview when electron closes + if (webProcess && !webProcess.killed && webProcess.pid) { + killProcessTree(webProcess.pid); + } + resolve(); + }); + }); + + break; + } else { + log('Invalid choice. Please enter 1 or 2.', 'red'); + } + } +} + +// Run main function +main().catch((err) => { + console.error(err); + cleanup(); + process.exit(1); +});