e6ae017609
* fix: eliminate Windows console popups during daemon spawn and Chroma operations Two changes to fix Windows Terminal popup issues: 1. Worker daemon spawn (ProcessManager.spawnDaemon): - Windows: Use WMIC to spawn truly independent process without console - WMIC creates processes that survive parent exit and have no console association - Properly handles paths with spaces via double-quoting - Unix: Unchanged behavior with standard detached spawn 2. PID file handling (worker-service.ts): - Worker now writes its own PID after listen() succeeds (all platforms) - Removes race condition where spawner wrote PID before worker was ready - On Windows, spawner PID was useless anyway 3. Chroma vector search (ChromaSync.ts): - Temporarily disabled on Windows to prevent MCP SDK subprocess popups - Will be re-enabled when we migrate to persistent HTTP server architecture - Windows users still get full observation storage, just no semantic search Tested on Windows 11 via SSH - worker spawns without console popups, survives parent process exit, and all lifecycle commands (start/stop/restart) work correctly. Fixes #748, #708, #681, #676, #675 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add YAML frontmatter to slash commands for discoverability Commands /do and /make-plan were not appearing in Claude Code because they lacked the required YAML frontmatter metadata. Added description and argument-hint fields to both commands. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: bigphoot <bigphoot@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
/**
|
|
* ProcessManager - PID files, signal handlers, and child process lifecycle management
|
|
*
|
|
* Extracted from worker-service.ts monolith to provide centralized process management.
|
|
* Handles:
|
|
* - PID file management for daemon coordination
|
|
* - Signal handler registration for graceful shutdown
|
|
* - Child process enumeration and cleanup (especially for Windows zombie port fix)
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { homedir } from 'os';
|
|
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
import { exec, execSync, spawn } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { logger } from '../../utils/logger.js';
|
|
import { HOOK_TIMEOUTS } from '../../shared/hook-constants.js';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// Standard paths for PID file management
|
|
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
|
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
|
|
|
export interface PidInfo {
|
|
pid: number;
|
|
port: number;
|
|
startedAt: string;
|
|
}
|
|
|
|
/**
|
|
* Write PID info to the standard PID file location
|
|
*/
|
|
export function writePidFile(info: PidInfo): void {
|
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
writeFileSync(PID_FILE, JSON.stringify(info, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Read PID info from the standard PID file location
|
|
* Returns null if file doesn't exist or is corrupted
|
|
*/
|
|
export function readPidFile(): PidInfo | null {
|
|
if (!existsSync(PID_FILE)) return null;
|
|
|
|
try {
|
|
return JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
|
} catch (error) {
|
|
logger.warn('SYSTEM', 'Failed to parse PID file', { path: PID_FILE }, error as Error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the PID file (called during shutdown)
|
|
*/
|
|
export function removePidFile(): void {
|
|
if (!existsSync(PID_FILE)) return;
|
|
|
|
try {
|
|
unlinkSync(PID_FILE);
|
|
} catch (error) {
|
|
// [ANTI-PATTERN IGNORED]: Cleanup function - PID file removal failure is non-critical
|
|
logger.warn('SYSTEM', 'Failed to remove PID file', { path: PID_FILE }, error as Error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get platform-adjusted timeout (Windows socket cleanup is slower)
|
|
*/
|
|
export function getPlatformTimeout(baseMs: number): number {
|
|
const WINDOWS_MULTIPLIER = 2.0;
|
|
return process.platform === 'win32' ? Math.round(baseMs * WINDOWS_MULTIPLIER) : baseMs;
|
|
}
|
|
|
|
/**
|
|
* Get all child process PIDs (Windows-specific)
|
|
* Used for cleanup to prevent zombie ports when parent exits
|
|
*/
|
|
export async function getChildProcesses(parentPid: number): Promise<number[]> {
|
|
if (process.platform !== 'win32') {
|
|
return [];
|
|
}
|
|
|
|
// SECURITY: Validate PID is a positive integer to prevent command injection
|
|
if (!Number.isInteger(parentPid) || parentPid <= 0) {
|
|
logger.warn('SYSTEM', 'Invalid parent PID for child process enumeration', { parentPid });
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
// PowerShell Get-Process instead of WMIC (deprecated in Windows 11)
|
|
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-Process | Where-Object { \\$_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty Id"`;
|
|
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
|
|
// PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format
|
|
return stdout
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0 && /^\d+$/.test(line))
|
|
.map(line => parseInt(line, 10))
|
|
.filter(pid => pid > 0);
|
|
} catch (error) {
|
|
// Shutdown cleanup - failure is non-critical, continue without child process cleanup
|
|
logger.error('SYSTEM', 'Failed to enumerate child processes', { parentPid }, error as Error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force kill a process by PID
|
|
* Windows: uses taskkill /F /T to kill process tree
|
|
* Unix: uses SIGKILL
|
|
*/
|
|
export async function forceKillProcess(pid: number): Promise<void> {
|
|
// SECURITY: Validate PID is a positive integer to prevent command injection
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
logger.warn('SYSTEM', 'Invalid PID for force kill', { pid });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
// /T kills entire process tree, /F forces termination
|
|
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
|
|
} else {
|
|
process.kill(pid, 'SIGKILL');
|
|
}
|
|
logger.info('SYSTEM', 'Killed process', { pid });
|
|
} catch (error) {
|
|
// [ANTI-PATTERN IGNORED]: Shutdown cleanup - process already exited, continue
|
|
logger.debug('SYSTEM', 'Process already exited during force kill', { pid }, error as Error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for processes to fully exit
|
|
*/
|
|
export async function waitForProcessesExit(pids: number[], timeoutMs: number): Promise<void> {
|
|
const start = Date.now();
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
const stillAlive = pids.filter(pid => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
// [ANTI-PATTERN IGNORED]: Tight loop checking 100s of PIDs every 100ms during cleanup
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (stillAlive.length === 0) {
|
|
logger.info('SYSTEM', 'All child processes exited');
|
|
return;
|
|
}
|
|
|
|
logger.debug('SYSTEM', 'Waiting for processes to exit', { stillAlive });
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
|
|
logger.warn('SYSTEM', 'Timeout waiting for child processes to exit');
|
|
}
|
|
|
|
/**
|
|
* Clean up orphaned chroma-mcp processes from previous worker sessions
|
|
* Prevents process accumulation and memory leaks
|
|
*/
|
|
export async function cleanupOrphanedProcesses(): Promise<void> {
|
|
const isWindows = process.platform === 'win32';
|
|
const pids: number[] = [];
|
|
|
|
try {
|
|
if (isWindows) {
|
|
// Windows: Use PowerShell Get-CimInstance instead of WMIC (deprecated in Windows 11)
|
|
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { \\$_.Name -like '*python*' -and \\$_.CommandLine -like '*chroma-mcp*' } | Select-Object -ExpandProperty ProcessId"`;
|
|
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
|
|
|
|
if (!stdout.trim()) {
|
|
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Windows)');
|
|
return;
|
|
}
|
|
|
|
// PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format
|
|
const lines = stdout
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0 && /^\d+$/.test(line));
|
|
|
|
for (const line of lines) {
|
|
const pid = parseInt(line, 10);
|
|
// SECURITY: Validate PID is positive integer before adding to list
|
|
if (!isNaN(pid) && Number.isInteger(pid) && pid > 0) {
|
|
pids.push(pid);
|
|
}
|
|
}
|
|
} else {
|
|
// Unix: Use ps aux | grep
|
|
const { stdout } = await execAsync('ps aux | grep "chroma-mcp" | grep -v grep || true');
|
|
|
|
if (!stdout.trim()) {
|
|
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Unix)');
|
|
return;
|
|
}
|
|
|
|
const lines = stdout.trim().split('\n');
|
|
for (const line of lines) {
|
|
const parts = line.trim().split(/\s+/);
|
|
if (parts.length > 1) {
|
|
const pid = parseInt(parts[1], 10);
|
|
// SECURITY: Validate PID is positive integer before adding to list
|
|
if (!isNaN(pid) && Number.isInteger(pid) && pid > 0) {
|
|
pids.push(pid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Orphan cleanup is non-critical - log and continue
|
|
logger.error('SYSTEM', 'Failed to enumerate orphaned processes', {}, error as Error);
|
|
return;
|
|
}
|
|
|
|
if (pids.length === 0) {
|
|
return;
|
|
}
|
|
|
|
logger.info('SYSTEM', 'Cleaning up orphaned chroma-mcp processes', {
|
|
platform: isWindows ? 'Windows' : 'Unix',
|
|
count: pids.length,
|
|
pids
|
|
});
|
|
|
|
// Kill all found processes
|
|
if (isWindows) {
|
|
for (const pid of pids) {
|
|
// SECURITY: Double-check PID validation before using in taskkill command
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
logger.warn('SYSTEM', 'Skipping invalid PID', { pid });
|
|
continue;
|
|
}
|
|
try {
|
|
execSync(`taskkill /PID ${pid} /T /F`, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND, stdio: 'ignore' });
|
|
} catch (error) {
|
|
// [ANTI-PATTERN IGNORED]: Cleanup loop - process may have exited, continue to next PID
|
|
logger.debug('SYSTEM', 'Failed to kill process, may have already exited', { pid }, error as Error);
|
|
}
|
|
}
|
|
} else {
|
|
for (const pid of pids) {
|
|
try {
|
|
process.kill(pid, 'SIGKILL');
|
|
} catch (error) {
|
|
// [ANTI-PATTERN IGNORED]: Cleanup loop - process may have exited, continue to next PID
|
|
logger.debug('SYSTEM', 'Process already exited', { pid }, error as Error);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pids.length });
|
|
}
|
|
|
|
/**
|
|
* Spawn a detached daemon process
|
|
* Returns the child PID or undefined if spawn failed
|
|
*
|
|
* On Windows, uses WMIC to spawn a truly independent process that
|
|
* survives parent exit without console popups. WMIC creates processes
|
|
* that are not associated with the parent's console.
|
|
*
|
|
* On Unix, uses standard detached spawn.
|
|
*
|
|
* PID file is written by the worker itself after listen() succeeds,
|
|
* not by the spawner (race-free, works on all platforms).
|
|
*/
|
|
export function spawnDaemon(
|
|
scriptPath: string,
|
|
port: number,
|
|
extraEnv: Record<string, string> = {}
|
|
): number | undefined {
|
|
const isWindows = process.platform === 'win32';
|
|
const env = {
|
|
...process.env,
|
|
CLAUDE_MEM_WORKER_PORT: String(port),
|
|
...extraEnv
|
|
};
|
|
|
|
if (isWindows) {
|
|
// Use WMIC to spawn a process that's independent of the parent console
|
|
// This avoids the console popup that occurs with detached: true
|
|
// Paths must be individually quoted for WMIC when they contain spaces
|
|
const execPath = process.execPath;
|
|
const script = scriptPath;
|
|
// WMIC command format: wmic process call create "\"path1\" \"path2\" args"
|
|
const command = `wmic process call create "\\"${execPath}\\" \\"${script}\\" --daemon"`;
|
|
|
|
try {
|
|
execSync(command, {
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
});
|
|
// WMIC returns immediately, we can't get the spawned PID easily
|
|
// Worker will write its own PID file after listen()
|
|
return 0;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Unix: standard detached spawn
|
|
const child = spawn(process.execPath, [scriptPath, '--daemon'], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
env
|
|
});
|
|
|
|
if (child.pid === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
child.unref();
|
|
|
|
return child.pid;
|
|
}
|
|
|
|
/**
|
|
* Create signal handler factory for graceful shutdown
|
|
* Returns a handler function that can be passed to process.on('SIGTERM') etc.
|
|
*/
|
|
export function createSignalHandler(
|
|
shutdownFn: () => Promise<void>,
|
|
isShuttingDownRef: { value: boolean }
|
|
): (signal: string) => Promise<void> {
|
|
return async (signal: string) => {
|
|
if (isShuttingDownRef.value) {
|
|
logger.warn('SYSTEM', `Received ${signal} but shutdown already in progress`);
|
|
return;
|
|
}
|
|
isShuttingDownRef.value = true;
|
|
|
|
logger.info('SYSTEM', `Received ${signal}, shutting down...`);
|
|
try {
|
|
await shutdownFn();
|
|
process.exit(0);
|
|
} catch (error) {
|
|
// Top-level signal handler - log any shutdown error and exit
|
|
logger.error('SYSTEM', 'Error during shutdown', {}, error as Error);
|
|
// Exit gracefully: Windows Terminal won't keep tab open on exit 0
|
|
// Even on shutdown errors, exit cleanly to prevent tab accumulation
|
|
process.exit(0);
|
|
}
|
|
};
|
|
}
|