UNPKG

mx-connect

Version:

Establish TCP connection to a MX server with MTA-STS and DANE/TLSA support

392 lines (329 loc) 11.9 kB
'use strict'; /** * Socket-specific memory leak test. * * Tests actual socket creation, timeout handling, and cleanup. * Run with: node --expose-gc test/memory-leak-socket-test.js */ const v8 = require('v8'); const net = require('net'); // Import the internal module directly to test socket handling const getConnectionModule = require('../../lib/get-connection'); if (typeof global.gc !== 'function') { console.error('ERROR: Run with --expose-gc flag:'); console.error(' node --expose-gc test/memory-leak-socket-test.js'); process.exit(1); } function getMemoryStats() { global.gc(); const heapStats = v8.getHeapStatistics(); return { heapUsed: heapStats.used_heap_size, external: process.memoryUsage().external }; } function formatBytes(bytes) { if (bytes < 1024) { return bytes + ' B'; } if (bytes < 1024 * 1024) { return (bytes / 1024).toFixed(2) + ' KB'; } return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; } // Create a test server that accepts and immediately closes connections function createTestServer() { return new Promise(resolve => { const server = net.createServer(socket => { // Immediately close the connection socket.end(); }); server.listen(0, '127.0.0.1', () => { const port = server.address().port; resolve({ server, port }); }); }); } // Create a test server that never responds (for timeout testing) // Tracks connected sockets for cleanup function createBlackHoleServer() { return new Promise(resolve => { const connectedSockets = []; const server = net.createServer(socket => { // Track sockets for cleanup connectedSockets.push(socket); // Never respond, just hold the connection }); server.connectedSockets = connectedSockets; server.listen(0, '127.0.0.1', () => { const port = server.address().port; resolve({ server, port }); }); }); } // Test 1: Successful connections with immediate close async function testSuccessfulConnections(port, iterations) { console.log(`\nTest 1: ${iterations} successful connections (server closes immediately)`); const baseline = getMemoryStats(); let successCount = 0; let errorCount = 0; for (let i = 0; i < iterations; i++) { try { const result = await getConnectionModule({ domain: 'test.local', mx: [ { exchange: 'localhost', priority: 0, A: ['127.0.0.1'], AAAA: [] } ], port, maxConnectTime: 5000 }); if (result.socket) { // The server already closed it, but ensure cleanup if (!result.socket.destroyed) { result.socket.destroy(); } } successCount++; } catch { errorCount++; } if ((i + 1) % 100 === 0) { process.stdout.write(` Progress: ${i + 1}/${iterations}\r`); } } global.gc(); global.gc(); await new Promise(r => setTimeout(r, 100)); global.gc(); const final = getMemoryStats(); const heapDiff = final.heapUsed - baseline.heapUsed; console.log(` Success: ${successCount}, Errors: ${errorCount}`); console.log(` Heap change: ${heapDiff >= 0 ? '+' : ''}${formatBytes(heapDiff)}`); console.log(` Per-connection: ${formatBytes(heapDiff / iterations)}`); return { heapDiff, perOp: heapDiff / iterations }; } // Test 2: Connection refused scenarios async function testConnectionRefused(iterations) { console.log(`\nTest 2: ${iterations} connection refused errors`); const baseline = getMemoryStats(); let errorCount = 0; for (let i = 0; i < iterations; i++) { try { await getConnectionModule({ domain: 'test.local', mx: [ { exchange: 'localhost', priority: 0, A: ['127.0.0.1'], AAAA: [] } ], port: 59999, // Port that should be closed maxConnectTime: 1000 }); } catch { errorCount++; } if ((i + 1) % 100 === 0) { process.stdout.write(` Progress: ${i + 1}/${iterations}\r`); } } global.gc(); global.gc(); await new Promise(r => setTimeout(r, 100)); global.gc(); const final = getMemoryStats(); const heapDiff = final.heapUsed - baseline.heapUsed; console.log(` Errors (expected): ${errorCount}`); console.log(` Heap change: ${heapDiff >= 0 ? '+' : ''}${formatBytes(heapDiff)}`); console.log(` Per-connection: ${formatBytes(heapDiff / iterations)}`); return { heapDiff, perOp: heapDiff / iterations }; } // Test 3: Connection timeout scenarios async function testConnectionTimeout(port, iterations) { console.log(`\nTest 3: ${iterations} connection timeouts`); const baseline = getMemoryStats(); let errorCount = 0; let successCount = 0; for (let i = 0; i < iterations; i++) { try { const result = await getConnectionModule({ domain: 'test.local', mx: [ { exchange: 'localhost', priority: 0, A: ['127.0.0.1'], AAAA: [] } ], port, maxConnectTime: 50 // Very short timeout }); // If connection succeeds (server accepts fast), destroy the socket if (result.socket && !result.socket.destroyed) { result.socket.destroy(); } successCount++; } catch { errorCount++; } if ((i + 1) % 50 === 0) { process.stdout.write(` Progress: ${i + 1}/${iterations}\r`); } } global.gc(); global.gc(); await new Promise(r => setTimeout(r, 500)); global.gc(); const final = getMemoryStats(); const heapDiff = final.heapUsed - baseline.heapUsed; console.log(` Success: ${successCount}, Timeouts: ${errorCount}`); console.log(` Heap change: ${heapDiff >= 0 ? '+' : ''}${formatBytes(heapDiff)}`); console.log(` Per-connection: ${formatBytes(heapDiff / iterations)}`); return { heapDiff, perOp: heapDiff / iterations }; } // Test 4: Multiple MX fallback with failures async function testMxFallback(successPort, iterations) { console.log(`\nTest 4: ${iterations} MX fallback scenarios (2 failures then success)`); const baseline = getMemoryStats(); let successCount = 0; let errorCount = 0; for (let i = 0; i < iterations; i++) { try { const result = await getConnectionModule({ domain: 'test.local', mx: [ { exchange: 'fail1.local', priority: 10, A: ['127.0.0.1'], AAAA: [] }, { exchange: 'fail2.local', priority: 20, A: ['127.0.0.1'], AAAA: [] }, { exchange: 'success.local', priority: 30, A: ['127.0.0.1'], AAAA: [] } ], port: successPort, maxConnectTime: 5000, connectError: () => {} // Suppress error logging }); if (result.socket && !result.socket.destroyed) { result.socket.destroy(); } successCount++; } catch { errorCount++; } if ((i + 1) % 100 === 0) { process.stdout.write(` Progress: ${i + 1}/${iterations}\r`); } } global.gc(); global.gc(); await new Promise(r => setTimeout(r, 100)); global.gc(); const final = getMemoryStats(); const heapDiff = final.heapUsed - baseline.heapUsed; console.log(` Success: ${successCount}, Errors: ${errorCount}`); console.log(` Heap change: ${heapDiff >= 0 ? '+' : ''}${formatBytes(heapDiff)}`); console.log(` Per-operation: ${formatBytes(heapDiff / iterations)}`); return { heapDiff, perOp: heapDiff / iterations }; } async function main() { console.log('Socket Memory Leak Test'); console.log('=======================\n'); // Create test servers console.log('Setting up test servers...'); const { server: normalServer, port: normalPort } = await createTestServer(); const { server: blackHoleServer, port: blackHolePort } = await createBlackHoleServer(); console.log(` Normal server on port ${normalPort}`); console.log(` Black hole server on port ${blackHolePort}`); // Warmup console.log('\nWarmup phase...'); for (let i = 0; i < 50; i++) { try { const result = await getConnectionModule({ domain: 'warmup.local', mx: [{ exchange: 'localhost', priority: 0, A: ['127.0.0.1'], AAAA: [] }], port: normalPort, maxConnectTime: 5000 }); if (result.socket && !result.socket.destroyed) { result.socket.destroy(); } } catch { // Ignore warmup errors } } global.gc(); global.gc(); await new Promise(r => setTimeout(r, 200)); global.gc(); const baselineHandles = process._getActiveHandles().length; console.log(`Baseline active handles: ${baselineHandles}`); // Run tests const results = []; results.push({ name: 'Successful connections', ...(await testSuccessfulConnections(normalPort, 500)) }); results.push({ name: 'Connection refused', ...(await testConnectionRefused(500)) }); results.push({ name: 'Connection timeout', ...(await testConnectionTimeout(blackHolePort, 100)) }); results.push({ name: 'MX fallback', ...(await testMxFallback(normalPort, 200)) }); // Final cleanup - destroy server-side sockets before closing if (blackHoleServer.connectedSockets) { for (const socket of blackHoleServer.connectedSockets) { if (!socket.destroyed) { socket.destroy(); } } } normalServer.close(); blackHoleServer.close(); await new Promise(r => setTimeout(r, 500)); global.gc(); global.gc(); const finalHandles = process._getActiveHandles().length; // Summary console.log('\n======================='); console.log('Summary:'); console.log('======================='); let hasIssues = false; for (const result of results) { const status = Math.abs(result.perOp) < 1024 ? 'OK' : 'WARN'; if (status === 'WARN') { hasIssues = true; } console.log(` ${result.name}: ${formatBytes(result.perOp)}/op [${status}]`); } const handleDiff = finalHandles - baselineHandles; console.log(`\n Handle change: ${handleDiff >= 0 ? '+' : ''}${handleDiff}`); if (handleDiff > 0) { hasIssues = true; console.log(' WARNING: Handles not cleaned up!'); console.log( ' Active handles:', process._getActiveHandles().map(h => h.constructor.name) ); } if (hasIssues) { console.log('\n POTENTIAL MEMORY ISSUES DETECTED'); process.exitCode = 1; } else { console.log('\n ALL TESTS PASSED - No memory leaks detected'); } } main().catch(err => { console.error('Test failed:', err); process.exit(1); });