feat(core): Add heartbeat grace/timeout options, client retry/wait-for-ready, server readiness and socket cleanup, transport socket options, helper utilities, and tests
This commit is contained in:
204
test/test.improvements.ts
Normal file
204
test/test.improvements.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartipc from '../ts/index.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
|
||||
const testSocketPath = path.join(os.tmpdir(), `test-ipc-improvements-${Date.now()}.sock`);
|
||||
|
||||
// Test 1: Server Readiness API
|
||||
tap.test('Server readiness API should emit ready event', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
heartbeat: false // Disable heartbeat for this test
|
||||
});
|
||||
|
||||
let readyEventFired = false;
|
||||
server.on('ready', () => {
|
||||
readyEventFired = true;
|
||||
});
|
||||
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
|
||||
expect(readyEventFired).toBeTrue();
|
||||
expect(server.getIsReady()).toBeTrue();
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Test 2: Automatic Socket Cleanup
|
||||
tap.test('Should cleanup stale socket file automatically', async () => {
|
||||
// Create a stale socket file
|
||||
fs.writeFileSync(testSocketPath, '');
|
||||
expect(fs.existsSync(testSocketPath)).toBeTrue();
|
||||
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
heartbeat: false // Disable heartbeat for this test
|
||||
});
|
||||
|
||||
// Should clean up and start successfully
|
||||
await server.start();
|
||||
expect(server.getIsReady()).toBeTrue();
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Test 3: Basic Connection with New Options
|
||||
tap.test('Client should connect with basic configuration', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
heartbeat: false // Disable heartbeat for this test
|
||||
});
|
||||
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
|
||||
// Wait for server to be fully ready
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
clientId: 'test-client',
|
||||
registerTimeoutMs: 10000 // Longer timeout
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Test 4: Heartbeat Configuration Without Throwing
|
||||
tap.test('Heartbeat should use event mode instead of throwing', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
heartbeat: false // Disable server heartbeat for this test
|
||||
});
|
||||
|
||||
// Add error handler to prevent unhandled errors
|
||||
server.on('error', () => {});
|
||||
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
clientId: 'heartbeat-client',
|
||||
heartbeat: true,
|
||||
heartbeatInterval: 100,
|
||||
heartbeatTimeout: 300,
|
||||
heartbeatInitialGracePeriodMs: 1000,
|
||||
heartbeatThrowOnTimeout: false // Don't throw, emit event
|
||||
});
|
||||
|
||||
let heartbeatTimeoutFired = false;
|
||||
client.on('heartbeatTimeout', () => {
|
||||
heartbeatTimeoutFired = true;
|
||||
});
|
||||
|
||||
client.on('error', () => {});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
// Wait a bit but within grace period
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Should still be connected, no timeout during grace period
|
||||
expect(heartbeatTimeoutFired).toBeFalse();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Test 5: Wait for Server Helper
|
||||
tap.test('waitForServer should detect when server becomes ready', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
heartbeat: false // Disable heartbeat for this test
|
||||
});
|
||||
|
||||
// Start server after delay
|
||||
setTimeout(async () => {
|
||||
await server.start();
|
||||
}, 200);
|
||||
|
||||
// Wait for server should succeed
|
||||
await smartipc.SmartIpc.waitForServer({
|
||||
socketPath: testSocketPath,
|
||||
timeoutMs: 3000
|
||||
});
|
||||
|
||||
// Server should be ready now
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
clientId: 'wait-test-client'
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Test 6: Connect Retry Configuration
|
||||
tap.test('Client retry should work with delayed server', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
heartbeat: false // Disable heartbeat for this test
|
||||
});
|
||||
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
clientId: 'retry-client',
|
||||
connectRetry: {
|
||||
enabled: true,
|
||||
initialDelay: 100,
|
||||
maxDelay: 500,
|
||||
maxAttempts: 10,
|
||||
totalTimeout: 5000
|
||||
}
|
||||
});
|
||||
|
||||
// Start server after a delay
|
||||
setTimeout(async () => {
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
}, 300);
|
||||
|
||||
// Client should retry and eventually connect
|
||||
await client.connect({ waitForReady: true, waitTimeout: 5000 });
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
tap.test('Cleanup test socket', async () => {
|
||||
try {
|
||||
fs.unlinkSync(testSocketPath);
|
||||
} catch (e) {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
286
test/test.reliability.ts
Normal file
286
test/test.reliability.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartipc from '../ts/index.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
|
||||
const testSocketPath = path.join(os.tmpdir(), `test-ipc-reliability-${Date.now()}.sock`);
|
||||
|
||||
tap.test('Server Readiness API', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true
|
||||
});
|
||||
|
||||
let readyEventFired = false;
|
||||
server.on('ready', () => {
|
||||
readyEventFired = true;
|
||||
});
|
||||
|
||||
// Start server with 'accepting' readiness mode
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
|
||||
// Check that ready event was fired
|
||||
expect(readyEventFired).toBeTrue();
|
||||
expect(server.getIsReady()).toBeTrue();
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
tap.test('Automatic Socket Cleanup', async () => {
|
||||
// Create a stale socket file
|
||||
fs.writeFileSync(testSocketPath, '');
|
||||
expect(fs.existsSync(testSocketPath)).toBeTrue();
|
||||
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
socketMode: 0o600
|
||||
});
|
||||
|
||||
// Should clean up stale socket and start successfully
|
||||
await server.start();
|
||||
expect(server.getIsReady()).toBeTrue();
|
||||
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
tap.test('Client Connection Retry', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'retry-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true
|
||||
});
|
||||
|
||||
// Create client with retry configuration
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'retry-client',
|
||||
socketPath: testSocketPath,
|
||||
connectRetry: {
|
||||
enabled: true,
|
||||
initialDelay: 50,
|
||||
maxDelay: 500,
|
||||
maxAttempts: 10,
|
||||
totalTimeout: 5000
|
||||
},
|
||||
registerTimeoutMs: 3000
|
||||
});
|
||||
|
||||
// Start server first with accepting readiness mode
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
|
||||
// Give server a moment to be fully ready
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Client should connect successfully with retry enabled
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
tap.test('Graceful Heartbeat Handling', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'heartbeat-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
heartbeat: true,
|
||||
heartbeatInterval: 100,
|
||||
heartbeatTimeout: 500,
|
||||
heartbeatInitialGracePeriodMs: 1000,
|
||||
heartbeatThrowOnTimeout: false
|
||||
});
|
||||
|
||||
// Add error handler to prevent unhandled error
|
||||
server.on('error', (error) => {
|
||||
// Ignore heartbeat errors in this test
|
||||
});
|
||||
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
|
||||
// Give server a moment to be fully ready
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'heartbeat-client',
|
||||
socketPath: testSocketPath,
|
||||
heartbeat: true,
|
||||
heartbeatInterval: 100,
|
||||
heartbeatTimeout: 500,
|
||||
heartbeatInitialGracePeriodMs: 1000,
|
||||
heartbeatThrowOnTimeout: false
|
||||
});
|
||||
|
||||
let heartbeatTimeoutFired = false;
|
||||
client.on('heartbeatTimeout', () => {
|
||||
heartbeatTimeoutFired = true;
|
||||
});
|
||||
|
||||
// Add error handler to prevent unhandled error
|
||||
client.on('error', (error) => {
|
||||
// Ignore errors in this test
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
// Wait to ensure heartbeat is working
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Heartbeat should not timeout during normal operation
|
||||
expect(heartbeatTimeoutFired).toBeFalse();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
tap.test('Test Helper - waitForServer', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'wait-test-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true
|
||||
});
|
||||
|
||||
// Start server after a delay
|
||||
setTimeout(() => {
|
||||
server.start();
|
||||
}, 100);
|
||||
|
||||
// Wait for server should succeed
|
||||
await smartipc.SmartIpc.waitForServer({
|
||||
socketPath: testSocketPath,
|
||||
timeoutMs: 3000
|
||||
});
|
||||
|
||||
// Server should be ready
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'wait-test-client',
|
||||
socketPath: testSocketPath
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
tap.test('Race Condition - Immediate Connect After Server Start', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'race-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true
|
||||
});
|
||||
|
||||
// Start server and immediately try to connect
|
||||
const serverPromise = server.start({ readyWhen: 'accepting' });
|
||||
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'race-client',
|
||||
socketPath: testSocketPath,
|
||||
connectRetry: {
|
||||
enabled: true,
|
||||
maxAttempts: 20,
|
||||
initialDelay: 10,
|
||||
maxDelay: 100
|
||||
},
|
||||
registerTimeoutMs: 5000
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await serverPromise;
|
||||
|
||||
// Client should be able to connect without race condition
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
// Test request/response to ensure full functionality
|
||||
server.onMessage('test', async (data) => {
|
||||
return { echo: data };
|
||||
});
|
||||
|
||||
const response = await client.request('test', { message: 'hello' });
|
||||
expect(response.echo.message).toEqual('hello');
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
tap.test('Multiple Clients with Retry', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'multi-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true,
|
||||
maxClients: 10
|
||||
});
|
||||
|
||||
await server.start({ readyWhen: 'accepting' });
|
||||
|
||||
// Create multiple clients with retry
|
||||
const clients = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: `client-${i}`,
|
||||
socketPath: testSocketPath,
|
||||
connectRetry: {
|
||||
enabled: true,
|
||||
maxAttempts: 5
|
||||
}
|
||||
});
|
||||
clients.push(client);
|
||||
}
|
||||
|
||||
// Connect all clients concurrently
|
||||
await Promise.all(clients.map(c => c.connect()));
|
||||
|
||||
// Verify all connected
|
||||
for (const client of clients) {
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
}
|
||||
|
||||
// Disconnect all
|
||||
await Promise.all(clients.map(c => c.disconnect()));
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
tap.test('Server Restart with Socket Cleanup', async () => {
|
||||
const server = smartipc.SmartIpc.createServer({
|
||||
id: 'restart-server',
|
||||
socketPath: testSocketPath,
|
||||
autoCleanupSocketFile: true
|
||||
});
|
||||
|
||||
// First start
|
||||
await server.start();
|
||||
expect(server.getIsReady()).toBeTrue();
|
||||
await server.stop();
|
||||
|
||||
// Second start - should cleanup and work
|
||||
await server.start();
|
||||
expect(server.getIsReady()).toBeTrue();
|
||||
|
||||
const client = smartipc.SmartIpc.createClient({
|
||||
id: 'restart-client',
|
||||
socketPath: testSocketPath
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
expect(client.getIsConnected()).toBeTrue();
|
||||
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Clean up test socket file
|
||||
tap.test('Cleanup', async () => {
|
||||
try {
|
||||
fs.unlinkSync(testSocketPath);
|
||||
} catch (e) {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
Reference in New Issue
Block a user