import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js'; const TEST_PORT = 2525; let testServer: ITestServer; const TEST_TIMEOUT = 60000; // Longer timeout for keepalive tests tap.test('Keepalive - should maintain TCP keepalive', async (tools) => { const done = tools.defer(); // Start test server testServer = await startTestServer({ port: TEST_PORT }); await new Promise(resolve => setTimeout(resolve, 1000)); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Enable TCP keepalive const keepAliveDelay = 1000; // 1 second socket.setKeepAlive(true, keepAliveDelay); console.log(`TCP keepalive enabled with ${keepAliveDelay}ms delay`); // Get banner const banner = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(banner).toInclude('220'); // Send EHLO socket.write('EHLO testhost\r\n'); const ehloResponse = await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); expect(ehloResponse).toInclude('250'); // Wait for keepalive duration + buffer console.log('Waiting for keepalive period...'); await new Promise(resolve => setTimeout(resolve, keepAliveDelay + 500)); // Verify connection is still alive by sending NOOP socket.write('NOOP\r\n'); const noopResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(noopResponse).toInclude('250'); console.log('Connection maintained after keepalive period'); // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { await stopTestServer(testServer); done.resolve(); } }); tap.test('Keepalive - should maintain idle connection for extended period', async (tools) => { const done = tools.defer(); // Start test server testServer = await startTestServer({ port: TEST_PORT }); await new Promise(resolve => setTimeout(resolve, 1000)); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Enable keepalive socket.setKeepAlive(true, 1000); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Test multiple keepalive periods const periods = 3; const periodDuration = 1000; // 1 second each for (let i = 0; i < periods; i++) { console.log(`Keepalive period ${i + 1}/${periods}...`); await new Promise(resolve => setTimeout(resolve, periodDuration)); // Send NOOP to verify connection socket.write('NOOP\r\n'); const response = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(response).toInclude('250'); console.log(`Connection alive after ${(i + 1) * periodDuration}ms`); } console.log(`Connection maintained for ${periods * periodDuration}ms total`); // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { await stopTestServer(testServer); done.resolve(); } }); tap.test('Keepalive - should detect connection loss', async (tools) => { const done = tools.defer(); // Start test server testServer = await startTestServer({ port: TEST_PORT }); await new Promise(resolve => setTimeout(resolve, 1000)); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Enable keepalive with short interval socket.setKeepAlive(true, 1000); // Track connection state let connectionLost = false; socket.on('close', () => { connectionLost = true; console.log('Connection closed'); }); socket.on('error', (err) => { connectionLost = true; console.log('Connection error:', err.message); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); console.log('Connection established, now simulating server shutdown...'); // Shutdown server to simulate connection loss await stopTestServer(testServer); // Wait for keepalive to detect connection loss await new Promise(resolve => setTimeout(resolve, 3000)); // Connection should be detected as lost expect(connectionLost).toEqual(true); console.log('Keepalive detected connection loss'); } finally { // Server already shutdown, just resolve done.resolve(); } }); tap.test('Keepalive - should handle long-running SMTP session', async (tools) => { const done = tools.defer(); // Start test server testServer = await startTestServer({ port: TEST_PORT }); await new Promise(resolve => setTimeout(resolve, 1000)); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Enable keepalive socket.setKeepAlive(true, 2000); const sessionStart = Date.now(); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Simulate a long-running session with periodic activity const activities = [ { command: 'MAIL FROM:', delay: 500 }, { command: 'RSET', delay: 500 }, { command: 'MAIL FROM:', delay: 500 }, { command: 'RSET', delay: 500 } ]; for (const activity of activities) { await new Promise(resolve => setTimeout(resolve, activity.delay)); console.log(`Sending: ${activity.command}`); socket.write(`${activity.command}\r\n`); const response = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(response).toInclude('250'); } const sessionDuration = Date.now() - sessionStart; console.log(`Long-running session maintained for ${sessionDuration}ms`); // Clean up socket.write('QUIT\r\n'); const quitResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(quitResponse).toInclude('221'); socket.end(); } finally { await stopTestServer(testServer); done.resolve(); } }); tap.test('Keepalive - should handle NOOP as keepalive mechanism', async (tools) => { const done = tools.defer(); // Start test server testServer = await startTestServer({ port: TEST_PORT }); await new Promise(resolve => setTimeout(resolve, 1000)); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Use NOOP as application-level keepalive const noopInterval = 1000; // 1 second const noopCount = 3; console.log(`Sending ${noopCount} NOOP commands as keepalive...`); for (let i = 0; i < noopCount; i++) { await new Promise(resolve => setTimeout(resolve, noopInterval)); socket.write('NOOP\r\n'); const response = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(response).toInclude('250'); console.log(`NOOP ${i + 1}/${noopCount} successful`); } console.log('Application-level keepalive successful'); // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { await stopTestServer(testServer); done.resolve(); } }); tap.start();