fix(core): Improve heartbeat handling and transport routing; forward heartbeat timeout events; include clientId routing and probe improvements
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartipc',
|
||||
version: '2.1.1',
|
||||
version: '2.1.2',
|
||||
description: 'A library for node inter process communication, providing an easy-to-use API for IPC.'
|
||||
}
|
||||
|
@@ -49,6 +49,7 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
|
||||
private reconnectTimer?: NodeJS.Timeout;
|
||||
private heartbeatTimer?: NodeJS.Timeout;
|
||||
private heartbeatCheckTimer?: NodeJS.Timeout;
|
||||
private heartbeatGraceTimer?: NodeJS.Timeout;
|
||||
private lastHeartbeat: number = Date.now();
|
||||
private connectionStartTime: number = Date.now();
|
||||
private isReconnecting = false;
|
||||
@@ -217,16 +218,27 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
|
||||
});
|
||||
}, this.options.heartbeatInterval!);
|
||||
|
||||
// Delay starting the check until after the grace period
|
||||
const gracePeriod = this.options.heartbeatInitialGracePeriodMs || 0;
|
||||
|
||||
if (gracePeriod > 0) {
|
||||
// Use a timer to delay the first check
|
||||
this.heartbeatGraceTimer = setTimeout(() => {
|
||||
this.startHeartbeatCheck();
|
||||
}, gracePeriod);
|
||||
} else {
|
||||
// No grace period, start checking immediately
|
||||
this.startHeartbeatCheck();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat timeout checking (separated for grace period handling)
|
||||
*/
|
||||
private startHeartbeatCheck(): void {
|
||||
// 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!) {
|
||||
const error = new Error('Heartbeat timeout');
|
||||
@@ -238,9 +250,11 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
|
||||
} else {
|
||||
// Emit heartbeatTimeout event instead of error
|
||||
this.emit('heartbeatTimeout', error);
|
||||
// Clear timers to avoid repeated events
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
}
|
||||
}, this.options.heartbeatTimeout! / 2);
|
||||
}, Math.max(1000, Math.floor(this.options.heartbeatTimeout! / 2)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +270,11 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
|
||||
clearInterval(this.heartbeatCheckTimer);
|
||||
this.heartbeatCheckTimer = undefined;
|
||||
}
|
||||
|
||||
if (this.heartbeatGraceTimer) {
|
||||
clearTimeout(this.heartbeatGraceTimer);
|
||||
this.heartbeatGraceTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -75,7 +75,10 @@ export class IpcClient extends plugins.EventEmitter {
|
||||
clientId: this.clientId,
|
||||
metadata: this.options.metadata
|
||||
},
|
||||
{ timeout: registerTimeoutMs }
|
||||
{
|
||||
timeout: registerTimeoutMs,
|
||||
headers: { clientId: this.clientId } // Include clientId in headers for proper routing
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
@@ -194,10 +197,20 @@ export class IpcClient extends plugins.EventEmitter {
|
||||
this.emit('disconnect', reason);
|
||||
});
|
||||
|
||||
this.channel.on('error', (error) => {
|
||||
this.channel.on('error', (error: any) => {
|
||||
// If heartbeat timeout and configured not to throw, convert to heartbeatTimeout event
|
||||
if (error && error.message === 'Heartbeat timeout' && this.options.heartbeatThrowOnTimeout === false) {
|
||||
this.emit('heartbeatTimeout', error);
|
||||
return;
|
||||
}
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
this.channel.on('heartbeatTimeout', (error) => {
|
||||
// Forward heartbeatTimeout event (when heartbeatThrowOnTimeout is false)
|
||||
this.emit('heartbeatTimeout', error);
|
||||
});
|
||||
|
||||
this.channel.on('reconnecting', (info) => {
|
||||
this.emit('reconnecting', info);
|
||||
});
|
||||
|
@@ -200,6 +200,11 @@ export class IpcServer extends plugins.EventEmitter {
|
||||
this.emit('error', error, 'server');
|
||||
});
|
||||
|
||||
this.primaryChannel.on('heartbeatTimeout', (error) => {
|
||||
// Forward heartbeatTimeout event (when heartbeatThrowOnTimeout is false)
|
||||
this.emit('heartbeatTimeout', error, 'server');
|
||||
});
|
||||
|
||||
// Connect the primary channel (will start as server)
|
||||
await this.primaryChannel.connect();
|
||||
|
||||
@@ -339,6 +344,19 @@ export class IpcServer extends plugins.EventEmitter {
|
||||
}
|
||||
this.emit('error', error, actualClientId);
|
||||
});
|
||||
|
||||
channel.on('heartbeatTimeout', (error) => {
|
||||
// Find the actual client ID for this channel
|
||||
let actualClientId = clientId;
|
||||
for (const [id, client] of this.clients) {
|
||||
if (client.channel === channel) {
|
||||
actualClientId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forward heartbeatTimeout event (when heartbeatThrowOnTimeout is false)
|
||||
this.emit('heartbeatTimeout', error, actualClientId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -169,6 +169,8 @@ export class UnixSocketTransport extends IpcTransport {
|
||||
private socket: plugins.net.Socket | null = null;
|
||||
private server: plugins.net.Server | null = null;
|
||||
private clients: Set<plugins.net.Socket> = new Set();
|
||||
private socketToClientId = new WeakMap<plugins.net.Socket, string>();
|
||||
private clientIdToSocket = new Map<string, plugins.net.Socket>();
|
||||
|
||||
/**
|
||||
* Connect as client or start as server
|
||||
@@ -239,6 +241,12 @@ export class UnixSocketTransport extends IpcTransport {
|
||||
|
||||
socket.on('close', () => {
|
||||
this.clients.delete(socket);
|
||||
// Clean up clientId mappings
|
||||
const clientId = this.socketToClientId.get(socket);
|
||||
if (clientId && this.clientIdToSocket.get(clientId) === socket) {
|
||||
this.clientIdToSocket.delete(clientId);
|
||||
}
|
||||
this.socketToClientId.delete(socket);
|
||||
this.emit('clientDisconnected', socket);
|
||||
});
|
||||
|
||||
@@ -307,7 +315,18 @@ export class UnixSocketTransport extends IpcTransport {
|
||||
// Parse and emit the message with socket reference
|
||||
try {
|
||||
const message = JSON.parse(messageData.toString('utf8')) as IIpcMessageEnvelope;
|
||||
|
||||
// Update clientId mapping
|
||||
const clientId = message.headers?.clientId ??
|
||||
(message.type === '__register__' ? (message.payload as any)?.clientId : undefined);
|
||||
if (clientId) {
|
||||
this.socketToClientId.set(socket, clientId);
|
||||
this.clientIdToSocket.set(clientId, socket);
|
||||
}
|
||||
|
||||
// Emit both events so IpcChannel can process it
|
||||
this.emit('clientMessage', message, socket);
|
||||
this.emit('message', message);
|
||||
} catch (error: any) {
|
||||
this.emit('error', new Error(`Failed to parse message: ${error.message}`));
|
||||
}
|
||||
@@ -415,27 +434,54 @@ export class UnixSocketTransport extends IpcTransport {
|
||||
}
|
||||
});
|
||||
} else if (this.server && this.clients.size > 0) {
|
||||
// Server mode - broadcast to all clients
|
||||
const promises: Promise<boolean>[] = [];
|
||||
// Server mode - route by clientId if present, otherwise broadcast
|
||||
const targetClientId = message.headers?.clientId;
|
||||
|
||||
for (const client of this.clients) {
|
||||
promises.push(new Promise((resolve) => {
|
||||
const success = client.write(frame, (error) => {
|
||||
if (error) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
if (targetClientId && this.clientIdToSocket.has(targetClientId)) {
|
||||
// Send to specific client
|
||||
const targetSocket = this.clientIdToSocket.get(targetClientId)!;
|
||||
if (targetSocket && !targetSocket.destroyed) {
|
||||
return new Promise((resolve) => {
|
||||
const success = targetSocket.write(frame, (error) => {
|
||||
if (error) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
targetSocket.once('drain', () => resolve(true));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Socket is destroyed, remove from mappings
|
||||
this.clientIdToSocket.delete(targetClientId);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all clients (fallback for messages without specific target)
|
||||
const promises: Promise<boolean>[] = [];
|
||||
|
||||
for (const client of this.clients) {
|
||||
promises.push(new Promise((resolve) => {
|
||||
const success = client.write(frame, (error) => {
|
||||
if (error) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
client.once('drain', () => resolve(true));
|
||||
}
|
||||
}));
|
||||
if (!success) {
|
||||
client.once('drain', () => resolve(true));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.every(r => r);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.every(r => r);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
21
ts/index.ts
21
ts/index.ts
@@ -29,20 +29,27 @@ export class SmartIpc {
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
// Try to connect as a temporary client
|
||||
const testClient = new IpcClient({
|
||||
id: `test-probe-${Date.now()}`,
|
||||
// Create a temporary client with proper options
|
||||
const testClient = SmartIpc.createClient({
|
||||
id: 'test-probe',
|
||||
socketPath: options.socketPath,
|
||||
autoReconnect: false,
|
||||
heartbeat: false
|
||||
clientId: `probe-${process.pid}-${Date.now()}`,
|
||||
heartbeat: false,
|
||||
connectRetry: {
|
||||
enabled: false // Don't retry, we're handling retries here
|
||||
},
|
||||
registerTimeoutMs: 2000 // Short timeout for quick probing
|
||||
});
|
||||
|
||||
// Try to connect and register with the server
|
||||
await testClient.connect();
|
||||
|
||||
// Success! Clean up and return
|
||||
await testClient.disconnect();
|
||||
return; // Server is ready
|
||||
return;
|
||||
} catch (error) {
|
||||
// Server not ready yet, wait and retry
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user