import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import * as plugins from '../ts/plugins.js'; // Import SmartProxy import { SmartProxy } from '../ts/index.js'; // Import types through type-only imports import type { ConnectionManager } from '../ts/proxies/smart-proxy/connection-manager.js'; import type { IConnectionRecord } from '../ts/proxies/smart-proxy/models/interfaces.js'; tap.test('zombie connection cleanup - verify inactivity check detects and cleans destroyed sockets', async () => { console.log('\n=== Zombie Connection Cleanup Test ==='); console.log('Purpose: Verify that connections with destroyed sockets are detected and cleaned up'); console.log('Setup: Client → OuterProxy (8590) → InnerProxy (8591) → Backend (9998)'); // Create backend server that can be controlled let acceptConnections = true; let destroyImmediately = false; const backendConnections: net.Socket[] = []; const backend = net.createServer((socket) => { console.log('Backend: Connection received'); backendConnections.push(socket); if (destroyImmediately) { console.log('Backend: Destroying connection immediately'); socket.destroy(); } else { socket.on('data', (data) => { console.log('Backend: Received data, echoing back'); socket.write(data); }); } }); await new Promise((resolve) => { backend.listen(9998, () => { console.log('✓ Backend server started on port 9998'); resolve(); }); }); // Create InnerProxy with faster inactivity check for testing const innerProxy = new SmartProxy({ ports: [8591], enableDetailedLogging: true, inactivityTimeout: 5000, // 5 seconds for faster testing inactivityCheckInterval: 1000, // Check every second routes: [{ name: 'to-backend', match: { ports: 8591 }, action: { type: 'forward', target: { host: 'localhost', port: 9998 } } }] }); // Create OuterProxy with faster inactivity check const outerProxy = new SmartProxy({ ports: [8590], enableDetailedLogging: true, inactivityTimeout: 5000, // 5 seconds for faster testing inactivityCheckInterval: 1000, // Check every second routes: [{ name: 'to-inner', match: { ports: 8590 }, action: { type: 'forward', target: { host: 'localhost', port: 8591 } } }] }); await innerProxy.start(); console.log('✓ InnerProxy started on port 8591'); await outerProxy.start(); console.log('✓ OuterProxy started on port 8590'); // Helper to get connection details const getConnectionDetails = () => { const outerConnMgr = (outerProxy as any).connectionManager as ConnectionManager; const innerConnMgr = (innerProxy as any).connectionManager as ConnectionManager; const outerRecords = Array.from((outerConnMgr as any).connectionRecords.values()) as IConnectionRecord[]; const innerRecords = Array.from((innerConnMgr as any).connectionRecords.values()) as IConnectionRecord[]; return { outer: { count: outerConnMgr.getConnectionCount(), records: outerRecords, zombies: outerRecords.filter(r => !r.connectionClosed && r.incoming?.destroyed && (r.outgoing?.destroyed ?? true) ), halfZombies: outerRecords.filter(r => !r.connectionClosed && (r.incoming?.destroyed || r.outgoing?.destroyed) && !(r.incoming?.destroyed && (r.outgoing?.destroyed ?? true)) ) }, inner: { count: innerConnMgr.getConnectionCount(), records: innerRecords, zombies: innerRecords.filter(r => !r.connectionClosed && r.incoming?.destroyed && (r.outgoing?.destroyed ?? true) ), halfZombies: innerRecords.filter(r => !r.connectionClosed && (r.incoming?.destroyed || r.outgoing?.destroyed) && !(r.incoming?.destroyed && (r.outgoing?.destroyed ?? true)) ) } }; }; console.log('\n--- Test 1: Create zombie by destroying sockets without events ---'); // Create a connection and forcefully destroy sockets to create zombies const client1 = new net.Socket(); await new Promise((resolve) => { client1.connect(8590, 'localhost', () => { console.log('Client1 connected to OuterProxy'); client1.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n'); // Wait for connection to be established through the chain setTimeout(() => { console.log('Forcefully destroying backend connections to create zombies'); // Get connection details before destruction const beforeDetails = getConnectionDetails(); console.log(`Before destruction: Outer=${beforeDetails.outer.count}, Inner=${beforeDetails.inner.count}`); // Destroy all backend connections without proper close events backendConnections.forEach(conn => { if (!conn.destroyed) { // Remove all listeners to prevent proper cleanup conn.removeAllListeners(); conn.destroy(); } }); // Also destroy the client socket abruptly client1.removeAllListeners(); client1.destroy(); resolve(); }, 500); }); }); // Check immediately after destruction await new Promise(resolve => setTimeout(resolve, 100)); let details = getConnectionDetails(); console.log(`\nAfter destruction:`); console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`); console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`); // Wait for inactivity check to run (should detect zombies) console.log('\nWaiting for inactivity check to detect zombies...'); await new Promise(resolve => setTimeout(resolve, 2000)); details = getConnectionDetails(); console.log(`\nAfter first inactivity check:`); console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`); console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`); console.log('\n--- Test 2: Create half-zombie by destroying only one socket ---'); // Clear backend connections array backendConnections.length = 0; const client2 = new net.Socket(); await new Promise((resolve) => { client2.connect(8590, 'localhost', () => { console.log('Client2 connected to OuterProxy'); client2.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n'); setTimeout(() => { console.log('Creating half-zombie by destroying only outgoing socket on outer proxy'); // Access the connection records directly const outerConnMgr = (outerProxy as any).connectionManager as ConnectionManager; const outerRecords = Array.from((outerConnMgr as any).connectionRecords.values()) as IConnectionRecord[]; // Find the active connection and destroy only its outgoing socket const activeRecord = outerRecords.find(r => !r.connectionClosed && r.outgoing && !r.outgoing.destroyed); if (activeRecord && activeRecord.outgoing) { console.log('Found active connection, destroying outgoing socket'); activeRecord.outgoing.removeAllListeners(); activeRecord.outgoing.destroy(); } resolve(); }, 500); }); }); // Check half-zombie state await new Promise(resolve => setTimeout(resolve, 100)); details = getConnectionDetails(); console.log(`\nAfter creating half-zombie:`); console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`); console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`); // Wait for 30-second grace period (simulated by multiple checks) console.log('\nWaiting for half-zombie grace period (30 seconds simulated)...'); // Manually age the connection to trigger half-zombie cleanup const outerConnMgr = (outerProxy as any).connectionManager as ConnectionManager; const records = Array.from((outerConnMgr as any).connectionRecords.values()) as IConnectionRecord[]; records.forEach(record => { if (!record.connectionClosed) { // Age the connection by 35 seconds record.incomingStartTime -= 35000; } }); // Trigger inactivity check await new Promise(resolve => setTimeout(resolve, 2000)); details = getConnectionDetails(); console.log(`\nAfter half-zombie cleanup:`); console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`); console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`); // Clean up client2 properly if (!client2.destroyed) { client2.destroy(); } console.log('\n--- Test 3: Rapid zombie creation under load ---'); // Create multiple connections rapidly and destroy them const rapidClients: net.Socket[] = []; for (let i = 0; i < 5; i++) { const client = new net.Socket(); rapidClients.push(client); client.connect(8590, 'localhost', () => { console.log(`Rapid client ${i} connected`); client.write('GET / HTTP/1.1\r\nHost: test.com\r\n\r\n'); // Destroy after random delay setTimeout(() => { client.removeAllListeners(); client.destroy(); }, Math.random() * 500); }); // Small delay between connections await new Promise(resolve => setTimeout(resolve, 50)); } // Wait a bit await new Promise(resolve => setTimeout(resolve, 1000)); details = getConnectionDetails(); console.log(`\nAfter rapid connections:`); console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`); console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`); // Wait for cleanup console.log('\nWaiting for final cleanup...'); await new Promise(resolve => setTimeout(resolve, 3000)); details = getConnectionDetails(); console.log(`\nFinal state:`); console.log(` Outer: ${details.outer.count} connections, ${details.outer.zombies.length} zombies, ${details.outer.halfZombies.length} half-zombies`); console.log(` Inner: ${details.inner.count} connections, ${details.inner.zombies.length} zombies, ${details.inner.halfZombies.length} half-zombies`); // Cleanup await outerProxy.stop(); await innerProxy.stop(); backend.close(); // Verify all connections are cleaned up console.log('\n--- Verification ---'); if (details.outer.count === 0 && details.inner.count === 0) { console.log('✅ PASS: All zombie connections were cleaned up'); } else { console.log('❌ FAIL: Some connections remain'); } expect(details.outer.count).toEqual(0); expect(details.inner.count).toEqual(0); expect(details.outer.zombies.length).toEqual(0); expect(details.inner.zombies.length).toEqual(0); expect(details.outer.halfZombies.length).toEqual(0); expect(details.inner.halfZombies.length).toEqual(0); }); tap.start();