/** * HealthMonitor - Port monitoring, health checks, and version checking * * Extracted from worker-service.ts monolith to provide centralized health monitoring. * Handles: * - Port availability checking * - Worker health/readiness polling * - Version mismatch detection (critical for plugin updates) * - HTTP-based shutdown requests */ import path from 'path'; import { readFileSync } from 'fs'; import { logger } from '../../utils/logger.js'; import { MARKETPLACE_ROOT } from '../../shared/paths.js'; /** * Check if a port is in use by querying the health endpoint */ export async function isPortInUse(port: number): Promise { try { // Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion) const response = await fetch(`http://127.0.0.1:${port}/api/health`); return response.ok; } catch (error) { // [ANTI-PATTERN IGNORED]: Health check polls every 500ms, logging would flood return false; } } /** * Wait for the worker HTTP server to become responsive (liveness check) * Uses /api/health instead of /api/readiness because: * - /api/health returns 200 as soon as HTTP server is listening * - /api/readiness waits for full initialization (MCP connection can take 5+ minutes) * See: https://github.com/thedotmack/claude-mem/issues/811 * @param port Worker port to check * @param timeoutMs Maximum time to wait in milliseconds * @returns true if worker became responsive, false if timeout */ export async function waitForHealth(port: number, timeoutMs: number = 30000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { // Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion) const response = await fetch(`http://127.0.0.1:${port}/api/health`); if (response.ok) return true; } catch (error) { // [ANTI-PATTERN IGNORED]: Retry loop - expected failures during startup, will retry logger.debug('SYSTEM', 'Service not ready yet, will retry', { port }, error as Error); } await new Promise(r => setTimeout(r, 500)); } return false; } /** * Wait for a port to become free (no longer responding to health checks) * Used after shutdown to confirm the port is available for restart */ export async function waitForPortFree(port: number, timeoutMs: number = 10000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (!(await isPortInUse(port))) return true; await new Promise(r => setTimeout(r, 500)); } return false; } /** * Send HTTP shutdown request to a running worker * @param port Worker port * @returns true if shutdown request was acknowledged, false otherwise */ export async function httpShutdown(port: number): Promise { try { // Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion) const response = await fetch(`http://127.0.0.1:${port}/api/admin/shutdown`, { method: 'POST' }); if (!response.ok) { logger.warn('SYSTEM', 'Shutdown request returned error', { port, status: response.status }); return false; } return true; } catch (error) { // Connection refused is expected if worker already stopped if (error instanceof Error && error.message?.includes('ECONNREFUSED')) { logger.debug('SYSTEM', 'Worker already stopped', { port }, error); return false; } // Unexpected error - log full details logger.error('SYSTEM', 'Shutdown request failed unexpectedly', { port }, error as Error); return false; } } /** * Get the plugin version from the installed marketplace package.json * This is the "expected" version that should be running */ export function getInstalledPluginVersion(): string { const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); return packageJson.version; } /** * Get the running worker's version via API * This is the "actual" version currently running */ export async function getRunningWorkerVersion(port: number): Promise { try { const response = await fetch(`http://127.0.0.1:${port}/api/version`); if (!response.ok) return null; const data = await response.json() as { version: string }; return data.version; } catch { // Expected: worker not running or version endpoint unavailable logger.debug('SYSTEM', 'Could not fetch worker version', { port }); return null; } } export interface VersionCheckResult { matches: boolean; pluginVersion: string; workerVersion: string | null; } /** * Check if worker version matches plugin version * Critical for detecting when plugin is updated but worker is still running old code * Returns true if versions match or if we can't determine (assume match for graceful degradation) */ export async function checkVersionMatch(port: number): Promise { const pluginVersion = getInstalledPluginVersion(); const workerVersion = await getRunningWorkerVersion(port); // If we can't get worker version, assume it matches (graceful degradation) if (!workerVersion) { return { matches: true, pluginVersion, workerVersion }; } return { matches: pluginVersion === workerVersion, pluginVersion, workerVersion }; }