@claude-vector/cli
Version:
CLI for Claude-integrated vector search
562 lines (464 loc) • 15.2 kB
JavaScript
/**
* ProcessController - プロセス管理・状態制御システム
*
* 機能:
* - PIDファイル管理(重複実行防止)
* - 状態ファイル管理(実行状況永続化)
* - グレースフルシャットダウン
* - プロセス存在確認
* - 信号ハンドリング
* - マルチプロセス制御
*/
import { EventEmitter } from 'events';
import fs from 'fs/promises';
import fsSync from 'fs';
import path from 'path';
import { spawn } from 'child_process';
/**
* プロセス制御のデフォルト設定
*/
const PROCESS_DEFAULTS = {
// ファイル名設定
pidFile: '.claude-watch.pid',
statusFile: '.claude-watch.status',
lockFile: '.claude-watch.lock',
// タイムアウト設定
shutdownTimeout: 30000, // 30秒でタイムアウト
startupTimeout: 10000, // 10秒で起動タイムアウト
healthCheckInterval: 5000, // 5秒間隔でヘルスチェック
// プロセス設定
allowMultiple: false, // 重複実行を許可しない
autoRestart: false, // 自動再起動しない
maxRestarts: 3, // 最大再起動回数
// ログ設定
logStdout: true, // 標準出力をログ
logStderr: true, // 標準エラーをログ
// デーモン設定
daemonMode: false, // デフォルトはフォアグラウンド
detached: false // プロセス分離
};
export class ProcessController extends EventEmitter {
constructor(config = {}) {
super();
this.config = { ...PROCESS_DEFAULTS, ...config };
this.projectRoot = null;
this.isInitialized = false;
// プロセス状態
this.state = {
pid: process.pid,
startTime: new Date(),
status: 'initializing',
parentPid: process.ppid,
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
cwd: process.cwd(),
// 統計情報
restartCount: 0,
lastRestart: null,
lastError: null,
// ヘルスチェック
lastHeartbeat: new Date(),
isHealthy: true
};
// ファイルパス
this.filePaths = {};
// タイマー
this.healthCheckTimer = null;
this.shutdownTimer = null;
// フラグ
this.isShuttingDown = false;
this.isLocked = false;
this.setupSignalHandlers();
}
/**
* 初期化
*/
async initialize(projectRoot) {
if (this.isInitialized) {
return;
}
try {
this.projectRoot = projectRoot;
// ファイルパスの設定
this.filePaths = {
pidFile: path.join(projectRoot, this.config.pidFile),
statusFile: path.join(projectRoot, this.config.statusFile),
lockFile: path.join(projectRoot, this.config.lockFile)
};
// 既存プロセスの確認
await this.checkExistingProcess();
// ロックファイルの作成
await this.acquireLock();
// PIDファイルの作成
await this.createPidFile();
// 状態ファイルの作成
await this.createStatusFile();
// ヘルスチェック開始
this.startHealthCheck();
this.state.status = 'running';
this.isInitialized = true;
this.emit('initialized', {
pid: this.state.pid,
projectRoot,
startTime: this.state.startTime
});
} catch (error) {
this.state.status = 'error';
this.state.lastError = error.message;
throw error;
}
}
/**
* 既存プロセス確認
*/
async checkExistingProcess() {
const pidFile = this.filePaths.pidFile;
if (!fsSync.existsSync(pidFile)) {
return; // PIDファイルがない場合は問題なし
}
try {
const pidContent = await fs.readFile(pidFile, 'utf-8');
const pid = parseInt(pidContent.trim());
if (isNaN(pid)) {
// 無効なPIDファイルは削除
await fs.unlink(pidFile);
return;
}
// プロセス存在確認
const isRunning = await this.isProcessRunning(pid);
if (isRunning) {
if (!this.config.allowMultiple) {
throw new Error(`Watch process already running (PID: ${pid}). Use 'ccvector watch stop' to stop it.`);
} else {
console.warn(`⚠️ Another watch process is running (PID: ${pid})`);
}
} else {
// 古いPIDファイルを削除
await fs.unlink(pidFile);
console.log('🧹 Cleaned up stale PID file');
}
} catch (error) {
if (error.code === 'ENOENT') {
return; // ファイルが存在しない
}
throw error;
}
}
/**
* プロセス存在確認
*/
async isProcessRunning(pid) {
try {
// kill(pid, 0) はプロセス存在確認に使用(実際にはkillしない)
process.kill(pid, 0);
return true;
} catch (error) {
if (error.code === 'ESRCH') {
return false; // プロセスが存在しない
} else if (error.code === 'EPERM') {
return true; // 権限がないが存在する
} else {
throw error;
}
}
}
/**
* ロック取得
*/
async acquireLock() {
const lockFile = this.filePaths.lockFile;
try {
// 排他制御でロックファイル作成
const lockData = {
pid: this.state.pid,
startTime: this.state.startTime.toISOString(),
nodeVersion: this.state.nodeVersion,
projectRoot: this.projectRoot
};
// O_EXCL フラグで排他作成(既存ファイルがあると失敗)
await fs.writeFile(lockFile, JSON.stringify(lockData, null, 2), { flag: 'wx' });
this.isLocked = true;
} catch (error) {
if (error.code === 'EEXIST') {
// ロックファイルが既に存在
const existingLock = JSON.parse(await fs.readFile(lockFile, 'utf-8'));
const isRunning = await this.isProcessRunning(existingLock.pid);
if (isRunning) {
throw new Error(`Another process has acquired lock (PID: ${existingLock.pid})`);
} else {
// 古いロックファイルを削除して再試行
await fs.unlink(lockFile);
await this.acquireLock();
}
} else {
throw error;
}
}
}
/**
* PIDファイル作成
*/
async createPidFile() {
const pidFile = this.filePaths.pidFile;
await fs.writeFile(pidFile, this.state.pid.toString());
}
/**
* 状態ファイル作成
*/
async createStatusFile() {
await this.updateStatusFile();
}
/**
* 状態ファイル更新
*/
async updateStatusFile() {
const statusFile = this.filePaths.statusFile;
const statusData = {
...this.state,
lastUpdate: new Date().toISOString(),
uptime: Date.now() - this.state.startTime.getTime(),
memoryUsage: process.memoryUsage(),
cpuUsage: process.cpuUsage()
};
try {
await fs.writeFile(statusFile, JSON.stringify(statusData, null, 2));
} catch (error) {
console.warn('⚠️ Failed to update status file:', error.message);
}
}
/**
* ヘルスチェック開始
*/
startHealthCheck() {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
try {
await this.performHealthCheck();
} catch (error) {
console.error('❌ Health check failed:', error.message);
this.state.isHealthy = false;
this.state.lastError = error.message;
}
}, this.config.healthCheckInterval);
}
/**
* ヘルスチェック実行
*/
async performHealthCheck() {
// メモリ使用量チェック
const memUsage = process.memoryUsage();
const memMB = Math.round(memUsage.heapUsed / 1024 / 1024);
if (memMB > 1000) { // 1GB以上
console.warn(`⚠️ High memory usage: ${memMB}MB`);
}
// ハートビート更新
this.state.lastHeartbeat = new Date();
this.state.isHealthy = true;
// 状態ファイル更新
await this.updateStatusFile();
this.emit('heartbeat', {
timestamp: this.state.lastHeartbeat,
memoryMB: memMB,
uptime: Date.now() - this.state.startTime.getTime()
});
}
/**
* 信号ハンドラ設定
*/
setupSignalHandlers() {
// SIGINT (Ctrl+C)
process.on('SIGINT', async () => {
console.log('\n🛑 Received SIGINT, shutting down gracefully...');
await this.shutdown('SIGINT');
});
// SIGTERM (kill)
process.on('SIGTERM', async () => {
console.log('\n🛑 Received SIGTERM, shutting down gracefully...');
await this.shutdown('SIGTERM');
});
// SIGHUP (reload)
process.on('SIGHUP', () => {
console.log('\n🔄 Received SIGHUP, reloading configuration...');
this.emit('reload');
});
// プロセス例外ハンドリング
process.on('uncaughtException', async (error) => {
console.error('❌ Uncaught exception:', error);
this.state.lastError = error.message;
await this.shutdown('uncaughtException');
});
process.on('unhandledRejection', async (reason) => {
console.error('❌ Unhandled rejection:', reason);
this.state.lastError = reason?.message || String(reason);
await this.shutdown('unhandledRejection');
});
}
/**
* グレースフルシャットダウン
*/
async shutdown(reason = 'manual') {
if (this.isShuttingDown) {
console.log('⏳ Shutdown already in progress...');
return;
}
this.isShuttingDown = true;
this.state.status = 'shutting_down';
console.log(`🛑 Starting graceful shutdown (reason: ${reason})...`);
try {
// シャットダウンタイマー設定
this.shutdownTimer = setTimeout(() => {
console.log('⚠️ Graceful shutdown timeout, forcing exit...');
process.exit(1);
}, this.config.shutdownTimeout);
// シャットダウンイベント発行
this.emit('shutdown-start', { reason, timestamp: new Date() });
// ヘルスチェック停止
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
// 状態ファイル最終更新
this.state.status = 'stopped';
await this.updateStatusFile();
// クリーンアップ
await this.cleanup();
console.log('✅ Graceful shutdown completed');
// シャットダウン完了イベント
this.emit('shutdown-complete', {
reason,
duration: Date.now() - this.state.startTime.getTime()
});
// タイマークリア
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
// プロセス終了
process.exit(0);
} catch (error) {
console.error('❌ Error during shutdown:', error.message);
process.exit(1);
}
}
/**
* クリーンアップ
*/
async cleanup() {
const cleanupTasks = [];
// PIDファイル削除
if (this.filePaths.pidFile && fsSync.existsSync(this.filePaths.pidFile)) {
cleanupTasks.push(fs.unlink(this.filePaths.pidFile));
}
// ロックファイル削除
if (this.isLocked && this.filePaths.lockFile && fsSync.existsSync(this.filePaths.lockFile)) {
cleanupTasks.push(fs.unlink(this.filePaths.lockFile));
}
// 状態ファイルは保持(デバッグ用)
try {
await Promise.all(cleanupTasks);
console.log('🧹 Cleanup completed');
} catch (error) {
console.warn('⚠️ Cleanup error:', error.message);
}
}
/**
* 状態取得
*/
async getStatus() {
// 現在の状態を返す
const currentStatus = {
...this.state,
uptime: Date.now() - this.state.startTime.getTime(),
isInitialized: this.isInitialized,
isShuttingDown: this.isShuttingDown,
isLocked: this.isLocked,
memoryUsage: process.memoryUsage(),
filePaths: this.filePaths
};
return currentStatus;
}
/**
* 実行中プロセス一覧取得
*/
static async getRunningProcesses(projectRoot) {
const pidFile = path.join(projectRoot, PROCESS_DEFAULTS.pidFile);
const statusFile = path.join(projectRoot, PROCESS_DEFAULTS.statusFile);
const processes = [];
if (fsSync.existsSync(pidFile) && fsSync.existsSync(statusFile)) {
try {
const pidContent = await fs.readFile(pidFile, 'utf-8');
const pid = parseInt(pidContent.trim());
const statusContent = await fs.readFile(statusFile, 'utf-8');
const status = JSON.parse(statusContent);
const controller = new ProcessController();
const isRunning = await controller.isProcessRunning(pid);
if (isRunning) {
processes.push({
pid,
status,
isRunning: true
});
}
} catch (error) {
// ファイル読み込みエラーは無視
}
}
return processes;
}
/**
* プロセス停止
*/
static async stopProcess(projectRoot, force = false) {
const pidFile = path.join(projectRoot, PROCESS_DEFAULTS.pidFile);
if (!fsSync.existsSync(pidFile)) {
throw new Error('No running watch process found');
}
try {
const pidContent = await fs.readFile(pidFile, 'utf-8');
const pid = parseInt(pidContent.trim());
const controller = new ProcessController();
const isRunning = await controller.isProcessRunning(pid);
if (!isRunning) {
// 古いPIDファイルを削除
await fs.unlink(pidFile);
throw new Error('Process is not running (cleaned up stale PID file)');
}
// プロセスにシグナル送信
const signal = force ? 'SIGKILL' : 'SIGTERM';
process.kill(pid, signal);
// グレースフル停止の場合は少し待機
if (!force) {
await new Promise(resolve => setTimeout(resolve, 2000));
// まだ動いているかチェック
const stillRunning = await controller.isProcessRunning(pid);
if (stillRunning) {
console.log('⏳ Process still running, sending SIGKILL...');
process.kill(pid, 'SIGKILL');
}
}
return { pid, signal, success: true };
} catch (error) {
if (error.code === 'ESRCH') {
// プロセスが存在しない
await fs.unlink(pidFile);
throw new Error('Process not found (cleaned up stale PID file)');
}
throw error;
}
}
/**
* デストラクタ
*/
async destroy() {
await this.cleanup();
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
}
}
}