FABRIC/src/heapDiff.ts
jedarden 6b39dae283 feat(memory): add heap diff analysis and leak detection utilities
- Add src/heapDiff.ts: utilities for comparing heap snapshots and analyzing trends
- Add API endpoints: /api/memory/diff-analysis, /api/memory/trend, /api/memory/trend.md
- Add docs/memory-audit-bd-ch6.7.md: comprehensive audit findings

Audit findings:
- Event store well-bounded with proper cleanup (1h stale worker, 5min collision timeout)
- WebSocket broadcast has backpressure handling (1MB buffer limit)
- Parser uses native JSON.parse(), no regex issues
- Heap snapshots already configured (30min intervals, 1GB heap limit)
- No unbounded growth identified in core data structures

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:05:39 -04:00

282 lines
8.6 KiB
TypeScript

/**
* FABRIC Heap Diff Analysis Utilities
*
* Utilities for analyzing heap snapshots to identify memory leaks.
* Provides comparison between snapshots and identifies growing retainers.
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
/** Snapshot directory */
const SNAPSHOT_DIR = join(homedir(), '.needle', 'snapshots');
/** Maximum number of snapshots to consider for diff analysis */
const MAX_SNAPSHOTS_FOR_DIFF = 10;
export interface HeapSnapshotSummary {
filename: string;
filepath: string;
timestamp: number;
sizeBytes: number;
sizeMb: number;
}
export interface HeapDiffResult {
baseline: HeapSnapshotSummary;
current: HeapSnapshotSummary;
durationMs: number;
durationMinutes: number;
sizeGrowthBytes: number;
sizeGrowthMb: number;
growthRateMbPerHour: number;
percentChange: number;
/** Objects that grew significantly (if detailed analysis available) */
growingObjects?: Array<{
name: string;
countDelta: number;
sizeDeltaBytes: number;
percentChange: number;
}>;
/** Assessment of whether this looks like a leak */
assessment: 'stable' | 'growing' | 'leaking' | 'unknown';
recommendations: string[];
}
/**
* Get all heap snapshots sorted by timestamp (oldest first)
*/
export function getHeapSnapshots(): HeapSnapshotSummary[] {
if (!existsSync(SNAPSHOT_DIR)) return [];
const files = readdirSync(SNAPSHOT_DIR)
.filter(f => f.endsWith('.heapsnapshot'))
.map(f => {
const filepath = join(SNAPSHOT_DIR, f);
const stat = statSync(filepath);
return {
filename: f,
filepath,
timestamp: stat.mtime.getTime(),
sizeBytes: stat.size,
sizeMb: stat.size / (1024 * 1024),
};
})
.sort((a, b) => a.timestamp - b.timestamp);
return files;
}
/**
* Compare two heap snapshots and return diff analysis
*/
export function compareSnapshots(
baseline: HeapSnapshotSummary,
current: HeapSnapshotSummary
): HeapDiffResult {
const durationMs = current.timestamp - baseline.timestamp;
const durationMinutes = durationMs / (1000 * 60);
const sizeGrowthBytes = current.sizeBytes - baseline.sizeBytes;
const sizeGrowthMb = sizeGrowthBytes / (1024 * 1024);
const percentChange = baseline.sizeBytes > 0
? (sizeGrowthBytes / baseline.sizeBytes) * 100
: 0;
const growthRateMbPerHour = durationMinutes > 0
? (sizeGrowthMb / durationMinutes) * 60
: 0;
// Determine assessment
let assessment: 'stable' | 'growing' | 'leaking' | 'unknown' = 'unknown';
const recommendations: string[] = [];
if (durationMinutes < 10) {
assessment = 'unknown';
recommendations.push('Insufficient time between snapshots for reliable assessment');
} else if (Math.abs(percentChange) < 5) {
assessment = 'stable';
recommendations.push('Memory usage appears stable');
} else if (percentChange > 0 && growthRateMbPerHour > 10) {
assessment = 'leaking';
recommendations.push(`Potential leak: growing at ${growthRateMbPerHour.toFixed(1)} MB/hour`);
recommendations.push('Review heap snapshot in Chrome DevTools for growing retainers');
recommendations.push('Check for unbounded collections in EventStore');
recommendations.push('Verify WebSocket client cleanup');
} else if (percentChange > 20) {
assessment = 'growing';
recommendations.push(`Memory growing: ${percentChange.toFixed(1)}% increase`);
recommendations.push('Monitor for continued growth');
} else {
assessment = 'stable';
recommendations.push('Memory growth within acceptable bounds');
}
return {
baseline,
current,
durationMs,
durationMinutes,
sizeGrowthBytes,
sizeGrowthMb,
growthRateMbPerHour,
percentChange,
assessment,
recommendations,
};
}
/**
* Get the most recent heap diff (comparing oldest recent vs newest)
*/
export function getRecentHeapDiff(): HeapDiffResult | null {
const snapshots = getHeapSnapshots();
if (snapshots.length < 2) return null;
// Use oldest of recent snapshots as baseline for comparison
const recent = snapshots.slice(-MAX_SNAPSHOTS_FOR_DIFF);
return compareSnapshots(recent[0], recent[recent.length - 1]);
}
/**
* Analyze trends across multiple snapshots
*/
export function analyzeTrend(): {
snapshots: HeapSnapshotSummary[];
diffs: HeapDiffResult[];
overallAssessment: 'stable' | 'growing' | 'leaking' | 'insufficient-data';
avgGrowthRateMbPerHour: number;
projectedGrowth24hMb: number;
} {
const snapshots = getHeapSnapshots();
const diffs: HeapDiffResult[] = [];
let totalGrowthRate = 0;
let growingCount = 0;
let leakingCount = 0;
if (snapshots.length < 2) {
return {
snapshots,
diffs,
overallAssessment: 'insufficient-data',
avgGrowthRateMbPerHour: 0,
projectedGrowth24hMb: 0,
};
}
// Compare consecutive snapshots
for (let i = 1; i < snapshots.length; i++) {
const diff = compareSnapshots(snapshots[i - 1], snapshots[i]);
diffs.push(diff);
totalGrowthRate += diff.growthRateMbPerHour;
if (diff.assessment === 'growing') growingCount++;
if (diff.assessment === 'leaking') leakingCount++;
}
const avgGrowthRateMbPerHour = totalGrowthRate / diffs.length;
const projectedGrowth24hMb = avgGrowthRateMbPerHour * 24;
let overallAssessment: 'stable' | 'growing' | 'leaking' | 'insufficient-data';
if (leakingCount >= diffs.length / 2) {
overallAssessment = 'leaking';
} else if (growingCount + leakingCount >= diffs.length * 0.7) {
overallAssessment = 'growing';
} else {
overallAssessment = 'stable';
}
return {
snapshots,
diffs,
overallAssessment,
avgGrowthRateMbPerHour,
projectedGrowth24hMb,
};
}
/**
* Format a heap diff result as markdown
*/
export function formatHeapDiffAsMarkdown(diff: HeapDiffResult): string {
const lines: string[] = [];
lines.push('# Heap Diff Analysis');
lines.push('');
lines.push(`**Baseline:** ${diff.baseline.filename}`);
lines.push(`**Current:** ${diff.current.filename}`);
lines.push(`**Duration:** ${diff.durationMinutes.toFixed(1)} minutes`);
lines.push('');
lines.push('## Memory Growth');
lines.push('');
lines.push(`| Metric | Value |`);
lines.push(`|--------|-------|`);
lines.push(`| Size Growth | ${diff.sizeGrowthMb.toFixed(2)} MB (${diff.percentChange > 0 ? '+' : ''}${diff.percentChange.toFixed(1)}%) |`);
lines.push(`| Growth Rate | ${diff.growthRateMbPerHour.toFixed(2)} MB/hour |`);
lines.push(`| Assessment | **${diff.assessment.toUpperCase()}** |`);
lines.push('');
if (diff.recommendations.length > 0) {
lines.push('## Recommendations');
lines.push('');
for (const rec of diff.recommendations) {
lines.push(`- ${rec}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* Format trend analysis as markdown
*/
export function formatTrendAsMarkdown(trend: ReturnType<typeof analyzeTrend>): string {
const lines: string[] = [];
lines.push('# Heap Trend Analysis');
lines.push('');
lines.push(`**Snapshots Analyzed:** ${trend.snapshots.length}`);
lines.push(`**Overall Assessment:** **${trend.overallAssessment.toUpperCase()}**`);
lines.push('');
lines.push('## Summary');
lines.push('');
lines.push(`| Metric | Value |`);
lines.push(`|--------|-------|`);
lines.push(`| Average Growth Rate | ${trend.avgGrowthRateMbPerHour.toFixed(2)} MB/hour |`);
lines.push(`| Projected 24h Growth | ${trend.projectedGrowth24hMb.toFixed(1)} MB |`);
lines.push('');
if (trend.diffs.length > 0) {
lines.push('## Individual Snapshot Comparisons');
lines.push('');
for (const diff of trend.diffs) {
lines.push(`### ${diff.baseline.filename}${diff.current.filename}`);
lines.push(`- Duration: ${diff.durationMinutes.toFixed(1)} min`);
lines.push(`- Growth: ${diff.sizeGrowthMb.toFixed(2)} MB (${diff.percentChange.toFixed(1)}%)`);
lines.push(`- Rate: ${diff.growthRateMbPerHour.toFixed(2)} MB/hour`);
lines.push(`- Assessment: **${diff.assessment}**`);
lines.push('');
}
}
return lines.join('\n');
}
/**
* Save trend analysis to disk
*/
export function saveTrendReport(): string | null {
const trend = analyzeTrend();
if (trend.overallAssessment === 'insufficient-data') return null;
const { writeFileSync, mkdirSync } = require('fs');
const reportsDir = join(SNAPSHOT_DIR, 'reports');
if (!existsSync(reportsDir)) {
mkdirSync(reportsDir, { recursive: true });
}
const filename = `trend-report-${Date.now()}.md`;
const filepath = join(reportsDir, filename);
writeFileSync(filepath, formatTrendAsMarkdown(trend), 'utf-8');
return filepath;
}