dcrouter/test/suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts
2025-05-25 19:05:43 +00:00

394 lines
12 KiB
TypeScript

import * as plugins from '@git.zone/tstest/tapbundle';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
const TEST_PORT = 2525;
let testServer;
interface ResourceMetrics {
timestamp: number;
memoryUsage: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
};
processInfo: {
pid: number;
uptime: number;
cpuUsage: NodeJS.CpuUsage;
};
}
interface LeakAnalysis {
memoryGrowthMB: number;
memoryTrend: number;
stabilityScore: number;
memoryLeakDetected: boolean;
resourcesStable: boolean;
samplesAnalyzed: number;
initialMemoryMB: number;
finalMemoryMB: number;
}
const captureResourceMetrics = async (): Promise<ResourceMetrics> => {
// Force GC if available before measurement
if (global.gc) {
global.gc();
await new Promise(resolve => setTimeout(resolve, 100));
}
const memUsage = process.memoryUsage();
return {
timestamp: Date.now(),
memoryUsage: {
rss: Math.round(memUsage.rss / 1024 / 1024 * 100) / 100,
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024 * 100) / 100,
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024 * 100) / 100,
external: Math.round(memUsage.external / 1024 / 1024 * 100) / 100
},
processInfo: {
pid: process.pid,
uptime: process.uptime(),
cpuUsage: process.cpuUsage()
}
};
};
// Helper function to wait for SMTP response
const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise<string> => {
return new Promise((resolve, reject) => {
let buffer = '';
const timer = setTimeout(() => {
socket.removeListener('data', handler);
reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`));
}, timeout);
const handler = (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split('\r\n');
// Check if we have a complete response
for (const line of lines) {
if (expectedCode) {
if (line.startsWith(expectedCode + ' ')) {
clearTimeout(timer);
socket.removeListener('data', handler);
resolve(buffer);
return;
}
} else {
// Any complete response line
if (line.match(/^\d{3} /)) {
clearTimeout(timer);
socket.removeListener('data', handler);
resolve(buffer);
return;
}
}
}
};
socket.on('data', handler);
});
};
const analyzeResourceLeaks = (initial: ResourceMetrics, samples: Array<{ operation: number; metrics: ResourceMetrics }>, final: ResourceMetrics): LeakAnalysis => {
const memoryGrowthMB = final.memoryUsage.heapUsed - initial.memoryUsage.heapUsed;
// Analyze memory trend over samples
let memoryTrend = 0;
if (samples.length > 1) {
const firstSample = samples[0].metrics.memoryUsage.heapUsed;
const lastSample = samples[samples.length - 1].metrics.memoryUsage.heapUsed;
memoryTrend = lastSample - firstSample;
}
// Calculate stability score based on memory variance
let stabilityScore = 1.0;
if (samples.length > 2) {
const memoryValues = samples.map(s => s.metrics.memoryUsage.heapUsed);
const average = memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length;
const variance = memoryValues.reduce((acc, val) => acc + Math.pow(val - average, 2), 0) / memoryValues.length;
const stdDev = Math.sqrt(variance);
stabilityScore = Math.max(0, 1 - (stdDev / average));
}
return {
memoryGrowthMB: Math.round(memoryGrowthMB * 100) / 100,
memoryTrend: Math.round(memoryTrend * 100) / 100,
stabilityScore: Math.round(stabilityScore * 100) / 100,
memoryLeakDetected: memoryGrowthMB > 50,
resourcesStable: stabilityScore > 0.8 && memoryGrowthMB < 25,
samplesAnalyzed: samples.length,
initialMemoryMB: initial.memoryUsage.heapUsed,
finalMemoryMB: final.memoryUsage.heapUsed
};
};
tap.test('prepare server', async () => {
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('REL-03: Resource leak detection - Memory leak analysis', async (tools) => {
const done = tools.defer();
const operationCount = 20;
const connections: net.Socket[] = [];
const samples: Array<{ operation: number; metrics: ResourceMetrics }> = [];
try {
const initialMetrics = await captureResourceMetrics();
console.log(`📊 Initial memory: ${initialMetrics.memoryUsage.heapUsed}MB`);
for (let i = 0; i < operationCount; i++) {
console.log(`🔄 Operation ${i + 1}/${operationCount}...`);
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: 30000
});
connections.push(socket);
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
// Read greeting
await waitForResponse(socket, '220');
// Send EHLO
socket.write(`EHLO leaktest-${i}\r\n`);
await waitForResponse(socket, '250');
// Complete email transaction
socket.write(`MAIL FROM:<sender${i}@example.com>\r\n`);
const mailResp = await waitForResponse(socket, '250');
expect(mailResp).toInclude('250');
socket.write(`RCPT TO:<recipient${i}@example.com>\r\n`);
const rcptResp = await waitForResponse(socket, '250');
expect(rcptResp).toInclude('250');
socket.write('DATA\r\n');
const dataResp = await waitForResponse(socket, '354');
expect(dataResp).toInclude('354');
const emailContent = [
`From: sender${i}@example.com`,
`To: recipient${i}@example.com`,
`Subject: Resource Leak Test ${i + 1}`,
`Message-ID: <leak-test-${i}-${Date.now()}@example.com>`,
'',
`This is resource leak test iteration ${i + 1}.`,
'.',
''
].join('\r\n');
socket.write(emailContent);
const sendResp = await waitForResponse(socket, '250');
expect(sendResp).toInclude('250');
socket.write('QUIT\r\n');
await waitForResponse(socket, '221');
socket.end();
// Capture metrics every 5 operations
if ((i + 1) % 5 === 0) {
const metrics = await captureResourceMetrics();
samples.push({
operation: i + 1,
metrics
});
console.log(`📈 Sample ${samples.length}: Memory ${metrics.memoryUsage.heapUsed}MB`);
}
// Small delay between operations
await new Promise(resolve => setTimeout(resolve, 100));
}
// Clean up all connections
connections.forEach(conn => {
if (!conn.destroyed) {
conn.destroy();
}
});
// Wait for cleanup
await new Promise(resolve => setTimeout(resolve, 2000));
const finalMetrics = await captureResourceMetrics();
const leakAnalysis = analyzeResourceLeaks(initialMetrics, samples, finalMetrics);
console.log('\n📊 Resource Leak Analysis:');
console.log(`Initial memory: ${leakAnalysis.initialMemoryMB}MB`);
console.log(`Final memory: ${leakAnalysis.finalMemoryMB}MB`);
console.log(`Memory growth: ${leakAnalysis.memoryGrowthMB}MB`);
console.log(`Memory trend: ${leakAnalysis.memoryTrend}MB`);
console.log(`Stability score: ${leakAnalysis.stabilityScore}`);
console.log(`Memory leak detected: ${leakAnalysis.memoryLeakDetected}`);
console.log(`Resources stable: ${leakAnalysis.resourcesStable}`);
expect(leakAnalysis.memoryLeakDetected).toEqual(false);
expect(leakAnalysis.resourcesStable).toEqual(true);
done.resolve();
} catch (error) {
connections.forEach(conn => conn.destroy());
done.reject(error);
}
});
tap.test('REL-03: Resource leak detection - Connection leak test', async (tools) => {
const done = tools.defer();
const abandonedConnections: net.Socket[] = [];
try {
console.log('\nTesting for connection resource leaks...');
// Create connections that are abandoned without proper cleanup
for (let i = 0; i < 10; i++) {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: 30000
});
abandonedConnections.push(socket);
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
// Read greeting but don't complete transaction
await new Promise<void>((resolve) => {
socket.once('data', () => resolve());
});
// Start but don't complete EHLO
socket.write(`EHLO abandoned-${i}\r\n`);
// Don't wait for response, just move to next
await new Promise(resolve => setTimeout(resolve, 50));
}
console.log('Created 10 abandoned connections');
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 2000));
// Try to create new connections - should still work
let newConnectionsSuccessful = 0;
for (let i = 0; i < 5; i++) {
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: 5000
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
socket.destroy();
reject(new Error('Connection timeout'));
}, 5000);
socket.once('connect', () => {
clearTimeout(timeout);
resolve();
});
socket.once('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
// Verify connection works
const greeting = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => {
resolve(chunk.toString());
});
});
if (greeting.includes('220')) {
newConnectionsSuccessful++;
socket.write('QUIT\r\n');
socket.end();
}
} catch (error) {
console.log(`New connection ${i + 1} failed:`, error.message);
}
}
// Clean up abandoned connections
abandonedConnections.forEach(conn => conn.destroy());
console.log(`New connections successful: ${newConnectionsSuccessful}/5`);
// Server should still accept new connections despite abandoned ones
expect(newConnectionsSuccessful).toBeGreaterThanOrEqual(4);
done.resolve();
} catch (error) {
abandonedConnections.forEach(conn => conn.destroy());
done.reject(error);
}
});
tap.test('REL-03: Resource leak detection - Rapid create/destroy cycles', async (tools) => {
const done = tools.defer();
const cycles = 30;
const initialMetrics = await captureResourceMetrics();
try {
console.log('\nTesting rapid connection create/destroy cycles...');
for (let i = 0; i < cycles; i++) {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: 5000
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
// Immediately destroy after connect
socket.destroy();
// Very short delay
await new Promise(resolve => setTimeout(resolve, 20));
if ((i + 1) % 10 === 0) {
console.log(`Completed ${i + 1} cycles`);
}
}
// Wait for resources to be released
await new Promise(resolve => setTimeout(resolve, 3000));
const finalMetrics = await captureResourceMetrics();
const memoryGrowth = finalMetrics.memoryUsage.heapUsed - initialMetrics.memoryUsage.heapUsed;
console.log(`Memory growth after ${cycles} cycles: ${memoryGrowth.toFixed(2)}MB`);
// Memory growth should be minimal
expect(memoryGrowth).toBeLessThan(10);
done.resolve();
} catch (error) {
done.reject(error);
}
});
tap.test('cleanup server', async () => {
await stopTestServer(testServer);
});
export default tap.start();