- 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>
282 lines
8.6 KiB
TypeScript
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;
|
|
}
|