reloaderoo
Version:
Hot-reload your MCP servers without restarting your AI coding assistant. Works excellently with VSCode MCP, well with Claude Code. A transparent development proxy for the Model Context Protocol that enables seamless server restarts during development.
370 lines • 15.6 kB
JavaScript
/**
* ProcessManager - Manages child MCP server lifecycle with crash detection, auto-restart, and health monitoring
*/
import { EventEmitter } from 'events';
import { spawn } from 'cross-spawn';
import { logger } from './mcp-logger.js';
import { ProcessState, ProxyErrorCode, DEFAULT_PROXY_CONFIG } from './types.js';
/**
* ProcessManager handles complete lifecycle of child MCP server processes with crash detection,
* auto-restart with exponential backoff, health monitoring, and graceful shutdown capabilities.
*/
export class ProcessManager extends EventEmitter {
config;
state;
constructor(config) {
super();
this.config = { ...DEFAULT_PROXY_CONFIG, ...config };
this.validateConfig(this.config);
this.state = {
childProcess: null,
state: ProcessState.STOPPED,
restartCount: 0,
lastRestartTime: null,
isShuttingDown: false,
restartTimeoutId: null,
operationTimeoutId: null
};
logger.info('ProcessManager initialized', {
childCommand: this.config.childCommand,
workingDirectory: this.config.workingDirectory,
autoRestart: this.config.autoRestart
});
}
/** Start the child MCP server process */
async spawn() {
if (this.state.state !== ProcessState.STOPPED &&
this.state.state !== ProcessState.UNAVAILABLE &&
this.state.state !== ProcessState.RESTARTING) {
throw this.createError(ProxyErrorCode.INVALID_CONFIG, `Cannot spawn process in state: ${this.state.state}`);
}
logger.info('Spawning child MCP server', {
command: this.config.childCommand,
args: this.config.childArgs
});
this.setState(ProcessState.STARTING);
try {
this.clearTimeouts();
const timeoutPromise = new Promise((_, reject) => {
this.state.operationTimeoutId = setTimeout(() => {
reject(this.createError(ProxyErrorCode.OPERATION_TIMEOUT, `Child process spawn timed out after ${Math.min(this.config.operationTimeout, 5000)}ms`));
}, Math.min(this.config.operationTimeout, 5000));
});
const spawnPromise = this.spawnChildProcess();
await Promise.race([spawnPromise, timeoutPromise]);
this.clearTimeouts();
this.setState(ProcessState.RUNNING);
this.resetRestartCount();
logger.info('Child MCP server spawned successfully', { pid: this.state.childProcess?.pid });
this.emit('started', this.state.childProcess.pid);
}
catch (error) {
this.clearTimeouts();
this.setState(ProcessState.UNAVAILABLE);
this.state.childProcess = null;
const proxyError = error instanceof Error && 'code' in error
? error
: this.createError(ProxyErrorCode.CHILD_START_FAILED, `Failed to spawn child process: ${error}`, { cause: error });
logger.error('Failed to spawn child MCP server, marking as unavailable', { error: proxyError.message });
this.emit('error', proxyError);
throw proxyError;
}
}
/** Restart the child process with optional configuration updates */
async restart(configUpdate) {
logger.info('Initiating child process restart', {
currentState: this.state.state,
restartCount: this.state.restartCount
});
// Allow restart from UNAVAILABLE state - this is how we retry failed children
if (this.state.state === ProcessState.UNAVAILABLE) {
logger.info('Attempting to restart from unavailable state');
this.state.restartCount = 0; // Reset restart count when trying from unavailable
}
if (this.state.restartCount >= this.config.restartLimit) {
const error = this.createError(ProxyErrorCode.RESTART_LIMIT_EXCEEDED, `Maximum restart attempts (${this.config.restartLimit}) exceeded`);
this.emit('restart-failed', error, this.state.restartCount);
throw error;
}
const restartStartTime = Date.now();
this.setState(ProcessState.RESTARTING);
this.emit('restarting', configUpdate ? 'configuration update' : 'manual restart');
try {
if (configUpdate) {
this.applyConfigUpdate(configUpdate);
}
if (this.state.childProcess && !this.state.childProcess.killed) {
await this.terminateChild();
}
const delay = this.calculateRestartDelay();
if (delay > 0) {
await this.sleep(delay);
}
this.state.restartCount++;
this.state.lastRestartTime = Date.now();
await this.spawn();
const restartTime = Date.now() - restartStartTime;
logger.info('Child process restart completed', { restartTime, pid: this.state.childProcess?.pid });
this.emit('restarted', this.state.childProcess.pid, restartTime);
}
catch (error) {
const proxyError = error instanceof Error && 'code' in error
? error
: this.createError(ProxyErrorCode.CHILD_START_FAILED, `Restart failed: ${error}`, { cause: error });
logger.error('Child process restart failed', { error: proxyError.message });
this.emit('restart-failed', proxyError, this.state.restartCount);
if (this.config.autoRestart && this.state.restartCount < this.config.restartLimit) {
this.scheduleAutoRestart('restart failure');
}
else {
this.setState(ProcessState.UNAVAILABLE);
throw proxyError;
}
}
}
/** Gracefully terminate the child process */
async terminate() {
logger.info('Terminating child process', { pid: this.state.childProcess?.pid });
this.state.isShuttingDown = true;
this.clearTimeouts();
try {
if (this.state.childProcess && !this.state.childProcess.killed) {
await this.terminateChild();
}
this.setState(ProcessState.STOPPED);
this.state.childProcess = null;
logger.info('Child process terminated successfully');
}
catch (error) {
logger.error('Error during child process termination', { error });
throw error;
}
finally {
this.state.isShuttingDown = false;
}
}
/** Get current process state */
getState() {
return this.state.state;
}
/** Check if the child process is healthy and responsive */
async isHealthy() {
if (!this.state.childProcess || this.state.state !== ProcessState.RUNNING) {
return false;
}
try {
const result = await this.performHealthCheck();
return result.healthy;
}
catch {
return false;
}
}
/** Get current restart count */
getRestartCount() {
return this.state.restartCount;
}
/** Get the current child process instance (if any) */
getChildProcess() {
return this.state.childProcess;
}
// Private implementation methods
async spawnChildProcess() {
return new Promise((resolve, reject) => {
try {
const commandParts = this.config.childCommand.split(' ');
const command = commandParts[0];
if (!command) {
throw new Error('Invalid child command');
}
const args = commandParts.slice(1);
const allArgs = [...args, ...this.config.childArgs];
const env = { ...process.env, ...this.config.environment };
const childProcess = spawn(command, allArgs, {
cwd: this.config.workingDirectory,
env,
stdio: ['pipe', 'pipe', 'pipe'],
detached: false
});
this.state.childProcess = childProcess;
let resolved = false;
childProcess.on('spawn', () => {
if (!resolved) {
resolved = true;
logger.debug('Child process spawn event received', { pid: childProcess.pid });
resolve();
}
});
childProcess.on('error', (error) => {
if (!resolved) {
resolved = true;
logger.error('Child process spawn error', { error: error.message });
reject(this.createError(ProxyErrorCode.CHILD_START_FAILED, `Spawn error: ${error.message}`, { cause: error }));
}
});
// Add immediate error detection for non-existent commands
process.nextTick(() => {
if (!childProcess.pid && !resolved) {
resolved = true;
reject(this.createError(ProxyErrorCode.CHILD_START_FAILED, `Failed to spawn child process: command may not exist`));
}
});
childProcess.on('exit', (code, signal) => {
this.handleChildExit(code, signal);
});
childProcess.stdout?.on('data', (data) => {
logger.debug('Child stdout data received', { length: data.length });
});
childProcess.stderr?.on('data', (data) => {
logger.debug('Child stderr data received', { data: data.toString().trim() });
});
}
catch (error) {
reject(this.createError(ProxyErrorCode.CHILD_START_FAILED, `Failed to spawn process: ${error}`, { cause: error }));
}
});
}
handleChildExit(code, signal) {
if (this.state.isShuttingDown) {
logger.debug('Child process exited during shutdown', { code, signal });
this.emit('stopped', code, signal);
return;
}
const isCrash = code !== 0 || (signal && signal !== 'SIGTERM');
if (isCrash) {
logger.warn('Child process crashed', { code, signal, restartCount: this.state.restartCount });
this.setState(ProcessState.CRASHED);
this.emit('crashed', code, signal, this.state.restartCount);
if (this.config.autoRestart && this.state.restartCount < this.config.restartLimit) {
this.scheduleAutoRestart('process crash');
}
else {
this.setState(ProcessState.STOPPED);
}
}
else {
logger.info('Child process exited normally', { code, signal });
this.setState(ProcessState.STOPPED);
this.emit('stopped', code, signal);
}
this.state.childProcess = null;
}
scheduleAutoRestart(reason) {
const delay = this.calculateRestartDelay();
logger.info('Scheduling automatic restart', { reason, delay, attempt: this.state.restartCount + 1 });
this.state.restartTimeoutId = setTimeout(async () => {
try {
await this.restart();
}
catch (error) {
logger.error('Scheduled restart failed', { error });
this.setState(ProcessState.STOPPED);
}
}, delay);
}
calculateRestartDelay() {
if (this.state.restartCount === 0) {
return 0;
}
// Exponential backoff: baseDelay * 2^(attempts-1), capped at 30 seconds
const baseDelay = this.config.restartDelay;
const exponentialDelay = baseDelay * Math.pow(2, this.state.restartCount - 1);
return Math.min(exponentialDelay, 30000);
}
async terminateChild() {
const child = this.state.childProcess;
if (!child || child.killed) {
return;
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
if (!child.killed) {
logger.warn('Graceful termination timeout, sending SIGKILL', { pid: child.pid });
child.kill('SIGKILL');
}
}, 5000);
child.on('exit', () => {
clearTimeout(timeout);
resolve();
});
logger.debug('Sending SIGTERM to child process', { pid: child.pid });
child.kill('SIGTERM');
});
}
async performHealthCheck() {
const startTime = Date.now();
try {
if (!this.state.childProcess || this.state.childProcess.killed) {
return { healthy: false, error: 'Process not running' };
}
const responseTime = Date.now() - startTime;
return { healthy: true, responseTime };
}
catch (error) {
return {
healthy: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
applyConfigUpdate(update) {
if (update.environment) {
this.config.environment = { ...this.config.environment, ...update.environment };
}
if (update.childArgs) {
this.config.childArgs = [...update.childArgs];
}
if (update.workingDirectory) {
this.config.workingDirectory = update.workingDirectory;
}
logger.info('Applied configuration update', { updatedKeys: Object.keys(update) });
}
resetRestartCount() {
if (this.state.restartCount > 0) {
logger.debug('Resetting restart count', { previousCount: this.state.restartCount });
this.state.restartCount = 0;
}
}
setState(newState) {
const oldState = this.state.state;
this.state.state = newState;
if (oldState !== newState) {
logger.debug('Process state changed', { from: oldState, to: newState });
}
}
clearTimeouts() {
if (this.state.restartTimeoutId) {
clearTimeout(this.state.restartTimeoutId);
this.state.restartTimeoutId = null;
}
if (this.state.operationTimeoutId) {
clearTimeout(this.state.operationTimeoutId);
this.state.operationTimeoutId = null;
}
}
validateConfig(config) {
if (!config.childCommand || config.childCommand.trim().length === 0) {
throw this.createError(ProxyErrorCode.INVALID_CONFIG, 'childCommand is required');
}
if (config.restartLimit < 0) {
throw this.createError(ProxyErrorCode.INVALID_CONFIG, 'restartLimit must be >= 0');
}
if (config.operationTimeout < 1000) {
throw this.createError(ProxyErrorCode.INVALID_CONFIG, 'operationTimeout must be >= 1000ms');
}
}
createError(code, message, context) {
const error = new Error(message);
error.code = code;
error.context = context || {};
return error;
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Check if child process is currently running */
isChildRunning() {
return this.state.state === ProcessState.RUNNING && this.state.childProcess !== null;
}
}
//# sourceMappingURL=process-manager.js.map