From dd25ffd3e4d5f03bb1c909c84a72f9df85357089 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 25 Aug 2025 13:37:31 +0000 Subject: [PATCH] feat(core): Add heartbeat grace/timeout options, client retry/wait-for-ready, server readiness and socket cleanup, transport socket options, helper utilities, and tests --- changelog.md | 10 ++ test/test.improvements.ts | 204 +++++++++++++++++++++++++++ test/test.reliability.ts | 286 ++++++++++++++++++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.ipcchannel.ts | 26 +++- ts/classes.ipcclient.ts | 141 ++++++++++++++++--- ts/classes.ipcserver.ts | 31 ++++- ts/classes.transports.ts | 25 +++- ts/index.ts | 85 ++++++++++- 9 files changed, 780 insertions(+), 30 deletions(-) create mode 100644 test/test.improvements.ts create mode 100644 test/test.reliability.ts diff --git a/changelog.md b/changelog.md index 17b9ba7..064b8e7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # 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) Patch release prep: bump patch version and release minor fixes diff --git a/test/test.improvements.ts b/test/test.improvements.ts new file mode 100644 index 0000000..8e65954 --- /dev/null +++ b/test/test.improvements.ts @@ -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(); \ No newline at end of file diff --git a/test/test.reliability.ts b/test/test.reliability.ts new file mode 100644 index 0000000..98967cd --- /dev/null +++ b/test/test.reliability.ts @@ -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(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e93501c..d054bb0 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts/classes.ipcchannel.ts b/ts/classes.ipcchannel.ts index 9929030..0477f0b 100644 --- a/ts/classes.ipcchannel.ts +++ b/ts/classes.ipcchannel.ts @@ -22,6 +22,10 @@ export interface IIpcChannelOptions extends IIpcTransportOptions { heartbeatInterval?: number; /** Heartbeat timeout in ms */ 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 extends plugins.EventEm private heartbeatTimer?: NodeJS.Timeout; private heartbeatCheckTimer?: NodeJS.Timeout; private lastHeartbeat: number = Date.now(); + private connectionStartTime: number = Date.now(); private isReconnecting = false; private isClosing = false; @@ -203,6 +208,7 @@ export class IpcChannel extends plugins.EventEm this.stopHeartbeat(); this.lastHeartbeat = Date.now(); + this.connectionStartTime = Date.now(); // Send heartbeat messages this.heartbeatTimer = setInterval(() => { @@ -214,9 +220,25 @@ export class IpcChannel extends plugins.EventEm // Check for heartbeat timeout this.heartbeatCheckTimer = setInterval(() => { 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!) { - this.emit('error', new Error('Heartbeat timeout')); - this.transport.disconnect().catch(() => {}); + const error = new Error('Heartbeat timeout'); + + 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); } diff --git a/ts/classes.ipcclient.ts b/ts/classes.ipcclient.ts index a7dac4b..74741f3 100644 --- a/ts/classes.ipcclient.ts +++ b/ts/classes.ipcclient.ts @@ -5,11 +5,35 @@ import type { IIpcChannelOptions } from './classes.ipcchannel.js'; /** * 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 { /** Client identifier */ clientId?: string; /** Client metadata */ metadata?: Record; + /** 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 */ - public async connect(): Promise { + public async connect(connectOptions: IClientConnectOptions = {}): Promise { if (this.isConnected) { return; } - // Connect the channel - await this.channel.connect(); + // Helper function to attempt registration + const attemptRegistration = async (): Promise => { + const registerTimeoutMs = this.options.registerTimeoutMs || 5000; + + try { + const response = await this.channel.request( + '__register__', + { + clientId: this.clientId, + metadata: this.options.metadata + }, + { timeout: registerTimeoutMs } + ); - // Register with the server - try { - const response = await this.channel.request( - '__register__', - { - clientId: this.clientId, - metadata: this.options.metadata - }, - { timeout: 5000 } - ); + if (!response.success) { + throw new Error(response.error || 'Registration failed'); + } - if (!response.success) { - 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 => { + 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; - this.emit('connect'); - } catch (error) { - await this.channel.disconnect(); - throw new Error(`Failed to register with server: ${error.message}`); + // All attempts failed + throw lastError || new Error('Failed to connect to server'); + }; + + // 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(); } } diff --git a/ts/classes.ipcserver.ts b/ts/classes.ipcserver.ts index c6a6a31..de3e8b3 100644 --- a/ts/classes.ipcserver.ts +++ b/ts/classes.ipcserver.ts @@ -5,11 +5,20 @@ import type { IIpcChannelOptions } from './classes.ipcchannel.js'; /** * Options for IPC Server */ +export interface IServerStartOptions { + /** When to consider server ready (default: 'socket-bound') */ + readyWhen?: 'socket-bound' | 'accepting'; +} + export interface IIpcServerOptions extends Omit { /** Maximum number of client connections */ maxClients?: number; /** Client idle timeout in ms */ 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 any | Promise>(); private primaryChannel?: IpcChannel; private isRunning = false; + private isReady = false; private clientIdleCheckTimer?: NodeJS.Timeout; // Pub/sub tracking @@ -50,7 +60,7 @@ export class IpcServer extends plugins.EventEmitter { /** * Start the server */ - public async start(): Promise { + public async start(options: IServerStartOptions = {}): Promise { if (this.isRunning) { return; } @@ -196,6 +206,18 @@ export class IpcServer extends plugins.EventEmitter { this.isRunning = true; this.startClientIdleCheck(); 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 }; } + + /** + * Check if server is ready to accept connections + */ + public getIsReady(): boolean { + return this.isReady; + } } \ No newline at end of file diff --git a/ts/classes.transports.ts b/ts/classes.transports.ts index 9604339..5adec6d 100644 --- a/ts/classes.transports.ts +++ b/ts/classes.transports.ts @@ -34,6 +34,10 @@ export interface IIpcTransportOptions { noDelay?: boolean; /** Maximum message size in bytes (default: 8MB) */ 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 { return new Promise((resolve, reject) => { - // Clean up stale socket file if it exists - try { - plugins.fs.unlinkSync(socketPath); - } catch (error) { - // File doesn't exist, that's fine + // Clean up stale socket file if autoCleanupSocketFile is enabled + if (this.options.autoCleanupSocketFile) { + try { + plugins.fs.unlinkSync(socketPath); + } catch (error) { + // File doesn't exist, that's fine + } } this.server = plugins.net.createServer((socket) => { @@ -247,6 +253,15 @@ export class UnixSocketTransport extends IpcTransport { this.server.on('error', reject); 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.emit('connect'); resolve(); diff --git a/ts/index.ts b/ts/index.ts index 7c5257f..0191393 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -7,7 +7,7 @@ import { IpcServer } from './classes.ipcserver.js'; import { IpcClient } from './classes.ipcclient.js'; import { IpcChannel } from './classes.ipcchannel.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'; /** @@ -17,6 +17,89 @@ export class SmartIpc { /** * 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 { + 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 { return new IpcServer(options); }