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:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-08-25 - 2.1.0 - feat(core)
|
||||||
|
Add heartbeat grace/timeout options, client retry/wait-for-ready, server readiness and socket cleanup, transport socket options, helper utilities, and tests
|
||||||
|
|
||||||
|
- IpcChannel: add heartbeatInitialGracePeriodMs and heartbeatThrowOnTimeout; emit 'heartbeatTimeout' event when configured instead of throwing and disconnecting immediately.
|
||||||
|
- IpcClient: add connectRetry configuration, registerTimeoutMs, waitForReady option and robust connect logic with exponential backoff and total timeout handling.
|
||||||
|
- IpcServer: add start option readyWhen ('accepting'), isReady/getIsReady API, autoCleanupSocketFile and socketMode support for managing stale socket files and permissions.
|
||||||
|
- Transports: support autoCleanupSocketFile and socketMode (cleanup stale socket files and set socket permissions where applicable).
|
||||||
|
- SmartIpc: add waitForServer helper to wait until a server is ready and spawnAndConnect helper to spawn a server process and connect a client.
|
||||||
|
- Tests: add comprehensive tests (test.improvements.ts and test.reliability.ts) covering readiness, socket cleanup, retries, heartbeat behavior, race conditions, multiple clients, and server restart scenarios.
|
||||||
|
|
||||||
## 2025-08-25 - 2.0.3 - fix(ipc)
|
## 2025-08-25 - 2.0.3 - fix(ipc)
|
||||||
Patch release prep: bump patch version and release minor fixes
|
Patch release prep: bump patch version and release minor fixes
|
||||||
|
|
||||||
|
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();
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartipc',
|
name: '@push.rocks/smartipc',
|
||||||
version: '2.0.3',
|
version: '2.1.0',
|
||||||
description: 'A library for node inter process communication, providing an easy-to-use API for IPC.'
|
description: 'A library for node inter process communication, providing an easy-to-use API for IPC.'
|
||||||
}
|
}
|
||||||
|
@@ -22,6 +22,10 @@ export interface IIpcChannelOptions extends IIpcTransportOptions {
|
|||||||
heartbeatInterval?: number;
|
heartbeatInterval?: number;
|
||||||
/** Heartbeat timeout in ms */
|
/** Heartbeat timeout in ms */
|
||||||
heartbeatTimeout?: number;
|
heartbeatTimeout?: number;
|
||||||
|
/** Initial grace period before heartbeat timeout in ms */
|
||||||
|
heartbeatInitialGracePeriodMs?: number;
|
||||||
|
/** Throw on heartbeat timeout (default: true, set false to emit event instead) */
|
||||||
|
heartbeatThrowOnTimeout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +50,7 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
|
|||||||
private heartbeatTimer?: NodeJS.Timeout;
|
private heartbeatTimer?: NodeJS.Timeout;
|
||||||
private heartbeatCheckTimer?: NodeJS.Timeout;
|
private heartbeatCheckTimer?: NodeJS.Timeout;
|
||||||
private lastHeartbeat: number = Date.now();
|
private lastHeartbeat: number = Date.now();
|
||||||
|
private connectionStartTime: number = Date.now();
|
||||||
private isReconnecting = false;
|
private isReconnecting = false;
|
||||||
private isClosing = false;
|
private isClosing = false;
|
||||||
|
|
||||||
@@ -203,6 +208,7 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
|
|||||||
|
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.lastHeartbeat = Date.now();
|
this.lastHeartbeat = Date.now();
|
||||||
|
this.connectionStartTime = Date.now();
|
||||||
|
|
||||||
// Send heartbeat messages
|
// Send heartbeat messages
|
||||||
this.heartbeatTimer = setInterval(() => {
|
this.heartbeatTimer = setInterval(() => {
|
||||||
@@ -214,9 +220,25 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
|
|||||||
// Check for heartbeat timeout
|
// Check for heartbeat timeout
|
||||||
this.heartbeatCheckTimer = setInterval(() => {
|
this.heartbeatCheckTimer = setInterval(() => {
|
||||||
const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeat;
|
const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeat;
|
||||||
|
const timeSinceConnection = Date.now() - this.connectionStartTime;
|
||||||
|
const gracePeriod = this.options.heartbeatInitialGracePeriodMs || 0;
|
||||||
|
|
||||||
|
// Skip timeout check during initial grace period
|
||||||
|
if (timeSinceConnection < gracePeriod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (timeSinceLastHeartbeat > this.options.heartbeatTimeout!) {
|
if (timeSinceLastHeartbeat > this.options.heartbeatTimeout!) {
|
||||||
this.emit('error', new Error('Heartbeat timeout'));
|
const error = new Error('Heartbeat timeout');
|
||||||
this.transport.disconnect().catch(() => {});
|
|
||||||
|
if (this.options.heartbeatThrowOnTimeout !== false) {
|
||||||
|
// Default behavior: emit error which may cause disconnect
|
||||||
|
this.emit('error', error);
|
||||||
|
this.transport.disconnect().catch(() => {});
|
||||||
|
} else {
|
||||||
|
// Emit heartbeatTimeout event instead of error
|
||||||
|
this.emit('heartbeatTimeout', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, this.options.heartbeatTimeout! / 2);
|
}, this.options.heartbeatTimeout! / 2);
|
||||||
}
|
}
|
||||||
|
@@ -5,11 +5,35 @@ import type { IIpcChannelOptions } from './classes.ipcchannel.js';
|
|||||||
/**
|
/**
|
||||||
* Options for IPC Client
|
* Options for IPC Client
|
||||||
*/
|
*/
|
||||||
|
export interface IConnectRetryConfig {
|
||||||
|
/** Enable connection retry */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Initial delay before first retry in ms */
|
||||||
|
initialDelay?: number;
|
||||||
|
/** Maximum delay between retries in ms */
|
||||||
|
maxDelay?: number;
|
||||||
|
/** Maximum number of attempts */
|
||||||
|
maxAttempts?: number;
|
||||||
|
/** Total timeout for all retry attempts in ms */
|
||||||
|
totalTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IClientConnectOptions {
|
||||||
|
/** Wait for server to be ready before attempting connection */
|
||||||
|
waitForReady?: boolean;
|
||||||
|
/** Timeout for waiting for server readiness in ms */
|
||||||
|
waitTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IIpcClientOptions extends IIpcChannelOptions {
|
export interface IIpcClientOptions extends IIpcChannelOptions {
|
||||||
/** Client identifier */
|
/** Client identifier */
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
/** Client metadata */
|
/** Client metadata */
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
|
/** Connection retry configuration */
|
||||||
|
connectRetry?: IConnectRetryConfig;
|
||||||
|
/** Registration timeout in ms (default: 5000) */
|
||||||
|
registerTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,34 +59,111 @@ export class IpcClient extends plugins.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Connect to the server
|
* Connect to the server
|
||||||
*/
|
*/
|
||||||
public async connect(): Promise<void> {
|
public async connect(connectOptions: IClientConnectOptions = {}): Promise<void> {
|
||||||
if (this.isConnected) {
|
if (this.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect the channel
|
// Helper function to attempt registration
|
||||||
await this.channel.connect();
|
const attemptRegistration = async (): Promise<void> => {
|
||||||
|
const registerTimeoutMs = this.options.registerTimeoutMs || 5000;
|
||||||
|
|
||||||
// Register with the server
|
try {
|
||||||
try {
|
const response = await this.channel.request<any, any>(
|
||||||
const response = await this.channel.request<any, any>(
|
'__register__',
|
||||||
'__register__',
|
{
|
||||||
{
|
clientId: this.clientId,
|
||||||
clientId: this.clientId,
|
metadata: this.options.metadata
|
||||||
metadata: this.options.metadata
|
},
|
||||||
},
|
{ timeout: registerTimeoutMs }
|
||||||
{ timeout: 5000 }
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
throw new Error(response.error || 'Registration failed');
|
throw new Error(response.error || 'Registration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = true;
|
||||||
|
this.emit('connect');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to register with server: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to attempt connection with retry
|
||||||
|
const attemptConnection = async (): Promise<void> => {
|
||||||
|
const retryConfig = this.options.connectRetry;
|
||||||
|
const maxAttempts = retryConfig?.maxAttempts || 1;
|
||||||
|
const initialDelay = retryConfig?.initialDelay || 100;
|
||||||
|
const maxDelay = retryConfig?.maxDelay || 1500;
|
||||||
|
const totalTimeout = retryConfig?.totalTimeout || 15000;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
let delay = initialDelay;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
// Check total timeout
|
||||||
|
if (totalTimeout && Date.now() - startTime > totalTimeout) {
|
||||||
|
throw new Error(`Connection timeout after ${totalTimeout}ms: ${lastError?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect the channel
|
||||||
|
await this.channel.connect();
|
||||||
|
|
||||||
|
// Attempt registration
|
||||||
|
await attemptRegistration();
|
||||||
|
return; // Success!
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
// Disconnect channel for retry
|
||||||
|
await this.channel.disconnect().catch(() => {});
|
||||||
|
|
||||||
|
// If this isn't the last attempt and retry is enabled, wait before retrying
|
||||||
|
if (attempt < maxAttempts && retryConfig?.enabled) {
|
||||||
|
// Check if we have time for another attempt
|
||||||
|
if (totalTimeout && Date.now() - startTime + delay > totalTimeout) {
|
||||||
|
break; // Will timeout, don't wait
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
// Exponential backoff with max limit
|
||||||
|
delay = Math.min(delay * 2, maxDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isConnected = true;
|
// All attempts failed
|
||||||
this.emit('connect');
|
throw lastError || new Error('Failed to connect to server');
|
||||||
} catch (error) {
|
};
|
||||||
await this.channel.disconnect();
|
|
||||||
throw new Error(`Failed to register with server: ${error.message}`);
|
// If waitForReady is specified, wait for server socket to exist first
|
||||||
|
if (connectOptions.waitForReady) {
|
||||||
|
const waitTimeout = connectOptions.waitTimeout || 10000;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < waitTimeout) {
|
||||||
|
try {
|
||||||
|
// Try to connect
|
||||||
|
await attemptConnection();
|
||||||
|
return; // Success!
|
||||||
|
} catch (error) {
|
||||||
|
// If it's a connection refused error, server might not be ready yet
|
||||||
|
if ((error as any).message?.includes('ECONNREFUSED') ||
|
||||||
|
(error as any).message?.includes('ENOENT')) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Other errors should be thrown
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Server not ready after ${waitTimeout}ms`);
|
||||||
|
} else {
|
||||||
|
// Normal connection attempt
|
||||||
|
await attemptConnection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -5,11 +5,20 @@ import type { IIpcChannelOptions } from './classes.ipcchannel.js';
|
|||||||
/**
|
/**
|
||||||
* Options for IPC Server
|
* Options for IPC Server
|
||||||
*/
|
*/
|
||||||
|
export interface IServerStartOptions {
|
||||||
|
/** When to consider server ready (default: 'socket-bound') */
|
||||||
|
readyWhen?: 'socket-bound' | 'accepting';
|
||||||
|
}
|
||||||
|
|
||||||
export interface IIpcServerOptions extends Omit<IIpcChannelOptions, 'autoReconnect' | 'reconnectDelay' | 'maxReconnectDelay' | 'reconnectMultiplier' | 'maxReconnectAttempts'> {
|
export interface IIpcServerOptions extends Omit<IIpcChannelOptions, 'autoReconnect' | 'reconnectDelay' | 'maxReconnectDelay' | 'reconnectMultiplier' | 'maxReconnectAttempts'> {
|
||||||
/** Maximum number of client connections */
|
/** Maximum number of client connections */
|
||||||
maxClients?: number;
|
maxClients?: number;
|
||||||
/** Client idle timeout in ms */
|
/** Client idle timeout in ms */
|
||||||
clientIdleTimeout?: number;
|
clientIdleTimeout?: number;
|
||||||
|
/** Automatically cleanup stale socket file on start (default: false) */
|
||||||
|
autoCleanupSocketFile?: boolean;
|
||||||
|
/** Socket file permissions mode (e.g. 0o600) */
|
||||||
|
socketMode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +41,7 @@ export class IpcServer extends plugins.EventEmitter {
|
|||||||
private messageHandlers = new Map<string, (payload: any, clientId: string) => any | Promise<any>>();
|
private messageHandlers = new Map<string, (payload: any, clientId: string) => any | Promise<any>>();
|
||||||
private primaryChannel?: IpcChannel;
|
private primaryChannel?: IpcChannel;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
private isReady = false;
|
||||||
private clientIdleCheckTimer?: NodeJS.Timeout;
|
private clientIdleCheckTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
// Pub/sub tracking
|
// Pub/sub tracking
|
||||||
@@ -50,7 +60,7 @@ export class IpcServer extends plugins.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Start the server
|
* Start the server
|
||||||
*/
|
*/
|
||||||
public async start(): Promise<void> {
|
public async start(options: IServerStartOptions = {}): Promise<void> {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -196,6 +206,18 @@ export class IpcServer extends plugins.EventEmitter {
|
|||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
this.startClientIdleCheck();
|
this.startClientIdleCheck();
|
||||||
this.emit('start');
|
this.emit('start');
|
||||||
|
|
||||||
|
// Handle readiness based on options
|
||||||
|
if (options.readyWhen === 'accepting') {
|
||||||
|
// Wait a bit to ensure handlers are fully set up
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready');
|
||||||
|
} else {
|
||||||
|
// Default: ready when socket is bound
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -505,4 +527,11 @@ export class IpcServer extends plugins.EventEmitter {
|
|||||||
uptime: this.primaryChannel ? Date.now() - (this.primaryChannel as any).connectedAt : undefined
|
uptime: this.primaryChannel ? Date.now() - (this.primaryChannel as any).connectedAt : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if server is ready to accept connections
|
||||||
|
*/
|
||||||
|
public getIsReady(): boolean {
|
||||||
|
return this.isReady;
|
||||||
|
}
|
||||||
}
|
}
|
@@ -34,6 +34,10 @@ export interface IIpcTransportOptions {
|
|||||||
noDelay?: boolean;
|
noDelay?: boolean;
|
||||||
/** Maximum message size in bytes (default: 8MB) */
|
/** Maximum message size in bytes (default: 8MB) */
|
||||||
maxMessageSize?: number;
|
maxMessageSize?: number;
|
||||||
|
/** Automatically cleanup stale socket file on start (default: false) */
|
||||||
|
autoCleanupSocketFile?: boolean;
|
||||||
|
/** Socket file permissions mode (e.g. 0o600) */
|
||||||
|
socketMode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,11 +210,13 @@ export class UnixSocketTransport extends IpcTransport {
|
|||||||
*/
|
*/
|
||||||
private async startServer(socketPath: string): Promise<void> {
|
private async startServer(socketPath: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Clean up stale socket file if it exists
|
// Clean up stale socket file if autoCleanupSocketFile is enabled
|
||||||
try {
|
if (this.options.autoCleanupSocketFile) {
|
||||||
plugins.fs.unlinkSync(socketPath);
|
try {
|
||||||
} catch (error) {
|
plugins.fs.unlinkSync(socketPath);
|
||||||
// File doesn't exist, that's fine
|
} catch (error) {
|
||||||
|
// File doesn't exist, that's fine
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.server = plugins.net.createServer((socket) => {
|
this.server = plugins.net.createServer((socket) => {
|
||||||
@@ -247,6 +253,15 @@ export class UnixSocketTransport extends IpcTransport {
|
|||||||
this.server.on('error', reject);
|
this.server.on('error', reject);
|
||||||
|
|
||||||
this.server.listen(socketPath, () => {
|
this.server.listen(socketPath, () => {
|
||||||
|
// Set socket permissions if specified
|
||||||
|
if (this.options.socketMode !== undefined && process.platform !== 'win32') {
|
||||||
|
try {
|
||||||
|
plugins.fs.chmodSync(socketPath, this.options.socketMode);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore permission errors, not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.emit('connect');
|
this.emit('connect');
|
||||||
resolve();
|
resolve();
|
||||||
|
85
ts/index.ts
85
ts/index.ts
@@ -7,7 +7,7 @@ import { IpcServer } from './classes.ipcserver.js';
|
|||||||
import { IpcClient } from './classes.ipcclient.js';
|
import { IpcClient } from './classes.ipcclient.js';
|
||||||
import { IpcChannel } from './classes.ipcchannel.js';
|
import { IpcChannel } from './classes.ipcchannel.js';
|
||||||
import type { IIpcServerOptions } from './classes.ipcserver.js';
|
import type { IIpcServerOptions } from './classes.ipcserver.js';
|
||||||
import type { IIpcClientOptions } from './classes.ipcclient.js';
|
import type { IIpcClientOptions, IConnectRetryConfig } from './classes.ipcclient.js';
|
||||||
import type { IIpcChannelOptions } from './classes.ipcchannel.js';
|
import type { IIpcChannelOptions } from './classes.ipcchannel.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +17,89 @@ export class SmartIpc {
|
|||||||
/**
|
/**
|
||||||
* Create an IPC server
|
* Create an IPC server
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Wait for a server to become ready at the given socket path
|
||||||
|
*/
|
||||||
|
public static async waitForServer(options: {
|
||||||
|
socketPath: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const timeout = options.timeoutMs || 10000;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
try {
|
||||||
|
// Try to connect as a temporary client
|
||||||
|
const testClient = new IpcClient({
|
||||||
|
id: `test-probe-${Date.now()}`,
|
||||||
|
socketPath: options.socketPath,
|
||||||
|
autoReconnect: false,
|
||||||
|
heartbeat: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await testClient.connect();
|
||||||
|
await testClient.disconnect();
|
||||||
|
return; // Server is ready
|
||||||
|
} catch (error) {
|
||||||
|
// Server not ready yet, wait and retry
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Server not ready at ${options.socketPath} after ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to spawn a server process and connect a client
|
||||||
|
*/
|
||||||
|
public static async spawnAndConnect(options: {
|
||||||
|
serverScript: string;
|
||||||
|
socketPath: string;
|
||||||
|
clientId?: string;
|
||||||
|
spawnOptions?: any;
|
||||||
|
connectRetry?: IConnectRetryConfig;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<{
|
||||||
|
client: IpcClient;
|
||||||
|
serverProcess: any;
|
||||||
|
}> {
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
|
|
||||||
|
// Spawn the server process
|
||||||
|
const serverProcess = spawn('node', [options.serverScript], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'pipe',
|
||||||
|
...options.spawnOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle server process errors
|
||||||
|
serverProcess.on('error', (error: Error) => {
|
||||||
|
console.error('Server process error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
|
await SmartIpc.waitForServer({
|
||||||
|
socketPath: options.socketPath,
|
||||||
|
timeoutMs: options.timeoutMs || 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and connect client
|
||||||
|
const client = new IpcClient({
|
||||||
|
id: options.clientId || 'test-client',
|
||||||
|
socketPath: options.socketPath,
|
||||||
|
connectRetry: options.connectRetry || {
|
||||||
|
enabled: true,
|
||||||
|
maxAttempts: 10,
|
||||||
|
initialDelay: 100,
|
||||||
|
maxDelay: 1000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect({ waitForReady: true });
|
||||||
|
|
||||||
|
return { client, serverProcess };
|
||||||
|
}
|
||||||
|
|
||||||
public static createServer(options: IIpcServerOptions): IpcServer {
|
public static createServer(options: IIpcServerOptions): IpcServer {
|
||||||
return new IpcServer(options);
|
return new IpcServer(options);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user