mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
Systematic cleanup of every error handling anti-pattern detected by the automated scanner. 289 issues fixed via code changes, 12 approved with specific technical justifications. Changes across 90 files: - GENERIC_CATCH (141): Added instanceof Error type discrimination - LARGE_TRY_BLOCK (82): Extracted helper methods to narrow try scope to ≤10 lines - NO_LOGGING_IN_CATCH (65): Added logger/console calls for error visibility - CATCH_AND_CONTINUE_CRITICAL_PATH (10): Added throw/return or approved overrides - ERROR_STRING_MATCHING (2): Approved with rationale (no typed error classes) - ERROR_MESSAGE_GUESSING (1): Replaced chained .includes() with documented pattern array - PROMISE_CATCH_NO_LOGGING (1): Added logging to .catch() handler Also fixes a detector bug where nested try/catch inside a catch block corrupted brace-depth tracking, causing false positives. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
281 lines
8.4 KiB
TypeScript
281 lines
8.4 KiB
TypeScript
import { ChildProcess } from 'child_process';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
import { homedir } from 'os';
|
|
import path from 'path';
|
|
import { logger } from '../utils/logger.js';
|
|
|
|
const REAP_SESSION_SIGTERM_TIMEOUT_MS = 5_000;
|
|
const REAP_SESSION_SIGKILL_TIMEOUT_MS = 1_000;
|
|
|
|
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
|
const DEFAULT_REGISTRY_PATH = path.join(DATA_DIR, 'supervisor.json');
|
|
|
|
export interface ManagedProcessInfo {
|
|
pid: number;
|
|
type: string;
|
|
sessionId?: string | number;
|
|
startedAt: string;
|
|
}
|
|
|
|
export interface ManagedProcessRecord extends ManagedProcessInfo {
|
|
id: string;
|
|
}
|
|
|
|
interface PersistedRegistry {
|
|
processes: Record<string, ManagedProcessInfo>;
|
|
}
|
|
|
|
export function isPidAlive(pid: number): boolean {
|
|
if (!Number.isInteger(pid) || pid < 0) return false;
|
|
if (pid === 0) return false;
|
|
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === 'EPERM') return true;
|
|
logger.debug('SYSTEM', 'PID check failed', { pid, code });
|
|
return false;
|
|
}
|
|
logger.warn('SYSTEM', 'PID check threw non-Error', { pid, error: String(error) });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export class ProcessRegistry {
|
|
private readonly registryPath: string;
|
|
private readonly entries = new Map<string, ManagedProcessInfo>();
|
|
private readonly runtimeProcesses = new Map<string, ChildProcess>();
|
|
private initialized = false;
|
|
|
|
constructor(registryPath: string = DEFAULT_REGISTRY_PATH) {
|
|
this.registryPath = registryPath;
|
|
}
|
|
|
|
initialize(): void {
|
|
if (this.initialized) return;
|
|
this.initialized = true;
|
|
|
|
mkdirSync(path.dirname(this.registryPath), { recursive: true });
|
|
|
|
if (!existsSync(this.registryPath)) {
|
|
this.persist();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const raw = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as PersistedRegistry;
|
|
const processes = raw.processes ?? {};
|
|
for (const [id, info] of Object.entries(processes)) {
|
|
this.entries.set(id, info);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', {
|
|
path: this.registryPath
|
|
}, error);
|
|
} else {
|
|
logger.warn('SYSTEM', 'Failed to parse supervisor registry, rebuilding', {
|
|
path: this.registryPath,
|
|
error: String(error)
|
|
});
|
|
}
|
|
this.entries.clear();
|
|
}
|
|
|
|
const removed = this.pruneDeadEntries();
|
|
if (removed > 0) {
|
|
logger.info('SYSTEM', 'Removed dead processes from supervisor registry', { removed });
|
|
}
|
|
this.persist();
|
|
}
|
|
|
|
register(id: string, processInfo: ManagedProcessInfo, processRef?: ChildProcess): void {
|
|
this.initialize();
|
|
this.entries.set(id, processInfo);
|
|
if (processRef) {
|
|
this.runtimeProcesses.set(id, processRef);
|
|
}
|
|
this.persist();
|
|
}
|
|
|
|
unregister(id: string): void {
|
|
this.initialize();
|
|
this.entries.delete(id);
|
|
this.runtimeProcesses.delete(id);
|
|
this.persist();
|
|
}
|
|
|
|
clear(): void {
|
|
this.entries.clear();
|
|
this.runtimeProcesses.clear();
|
|
this.persist();
|
|
}
|
|
|
|
getAll(): ManagedProcessRecord[] {
|
|
this.initialize();
|
|
return Array.from(this.entries.entries())
|
|
.map(([id, info]) => ({ id, ...info }))
|
|
.sort((a, b) => {
|
|
const left = Date.parse(a.startedAt);
|
|
const right = Date.parse(b.startedAt);
|
|
return (Number.isNaN(left) ? 0 : left) - (Number.isNaN(right) ? 0 : right);
|
|
});
|
|
}
|
|
|
|
getBySession(sessionId: string | number): ManagedProcessRecord[] {
|
|
const normalized = String(sessionId);
|
|
return this.getAll().filter(record => record.sessionId !== undefined && String(record.sessionId) === normalized);
|
|
}
|
|
|
|
getRuntimeProcess(id: string): ChildProcess | undefined {
|
|
return this.runtimeProcesses.get(id);
|
|
}
|
|
|
|
getByPid(pid: number): ManagedProcessRecord[] {
|
|
return this.getAll().filter(record => record.pid === pid);
|
|
}
|
|
|
|
pruneDeadEntries(): number {
|
|
this.initialize();
|
|
|
|
let removed = 0;
|
|
for (const [id, info] of this.entries) {
|
|
if (isPidAlive(info.pid)) continue;
|
|
this.entries.delete(id);
|
|
this.runtimeProcesses.delete(id);
|
|
removed += 1;
|
|
}
|
|
|
|
if (removed > 0) {
|
|
this.persist();
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Kill and unregister all processes tagged with the given sessionId.
|
|
* Sends SIGTERM first, waits up to 5s, then SIGKILL for survivors.
|
|
* Called when a session is deleted to prevent leaked child processes (#1351).
|
|
*/
|
|
async reapSession(sessionId: string | number): Promise<number> {
|
|
this.initialize();
|
|
|
|
const sessionRecords = this.getBySession(sessionId);
|
|
if (sessionRecords.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const sessionIdNum = typeof sessionId === 'number' ? sessionId : Number(sessionId) || undefined;
|
|
logger.info('SYSTEM', `Reaping ${sessionRecords.length} process(es) for session ${sessionId}`, {
|
|
sessionId: sessionIdNum,
|
|
pids: sessionRecords.map(r => r.pid)
|
|
});
|
|
|
|
// Phase 1: SIGTERM all alive processes
|
|
const aliveRecords = sessionRecords.filter(r => isPidAlive(r.pid));
|
|
for (const record of aliveRecords) {
|
|
try {
|
|
process.kill(record.pid, 'SIGTERM');
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code !== 'ESRCH') {
|
|
logger.debug('SYSTEM', `Failed to SIGTERM session process PID ${record.pid}`, {
|
|
pid: record.pid
|
|
}, error);
|
|
}
|
|
} else {
|
|
logger.warn('SYSTEM', `Failed to SIGTERM session process PID ${record.pid} (non-Error)`, {
|
|
pid: record.pid,
|
|
error: String(error)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 2: Wait for processes to exit
|
|
const deadline = Date.now() + REAP_SESSION_SIGTERM_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
const survivors = aliveRecords.filter(r => isPidAlive(r.pid));
|
|
if (survivors.length === 0) break;
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
|
|
// Phase 3: SIGKILL any survivors
|
|
const survivors = aliveRecords.filter(r => isPidAlive(r.pid));
|
|
for (const record of survivors) {
|
|
logger.warn('SYSTEM', `Session process PID ${record.pid} did not exit after SIGTERM, sending SIGKILL`, {
|
|
pid: record.pid,
|
|
sessionId: sessionIdNum
|
|
});
|
|
try {
|
|
process.kill(record.pid, 'SIGKILL');
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code !== 'ESRCH') {
|
|
logger.debug('SYSTEM', `Failed to SIGKILL session process PID ${record.pid}`, {
|
|
pid: record.pid
|
|
}, error);
|
|
}
|
|
} else {
|
|
logger.warn('SYSTEM', `Failed to SIGKILL session process PID ${record.pid} (non-Error)`, {
|
|
pid: record.pid,
|
|
error: String(error)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Brief wait for SIGKILL to take effect
|
|
if (survivors.length > 0) {
|
|
const sigkillDeadline = Date.now() + REAP_SESSION_SIGKILL_TIMEOUT_MS;
|
|
while (Date.now() < sigkillDeadline) {
|
|
const remaining = survivors.filter(r => isPidAlive(r.pid));
|
|
if (remaining.length === 0) break;
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
|
|
// Phase 4: Unregister all session records
|
|
for (const record of sessionRecords) {
|
|
this.entries.delete(record.id);
|
|
this.runtimeProcesses.delete(record.id);
|
|
}
|
|
this.persist();
|
|
|
|
logger.info('SYSTEM', `Reaped ${sessionRecords.length} process(es) for session ${sessionId}`, {
|
|
sessionId: sessionIdNum,
|
|
reaped: sessionRecords.length
|
|
});
|
|
|
|
return sessionRecords.length;
|
|
}
|
|
|
|
private persist(): void {
|
|
const payload: PersistedRegistry = {
|
|
processes: Object.fromEntries(this.entries.entries())
|
|
};
|
|
|
|
mkdirSync(path.dirname(this.registryPath), { recursive: true });
|
|
writeFileSync(this.registryPath, JSON.stringify(payload, null, 2));
|
|
}
|
|
}
|
|
|
|
let registrySingleton: ProcessRegistry | null = null;
|
|
|
|
export function getProcessRegistry(): ProcessRegistry {
|
|
if (!registrySingleton) {
|
|
registrySingleton = new ProcessRegistry();
|
|
}
|
|
return registrySingleton;
|
|
}
|
|
|
|
export function createProcessRegistry(registryPath: string): ProcessRegistry {
|
|
return new ProcessRegistry(registryPath);
|
|
}
|