Files
tspm/ts/client/tspm.ipcclient.ts

359 lines
10 KiB
TypeScript

import * as plugins from './plugins.js';
import * as paths from '../paths.js';
import { toProcessId } from '../shared/protocol/id.js';
import type { ProcessId } from '../shared/protocol/id.js';
import type {
IpcMethodMap,
RequestForMethod,
ResponseForMethod,
} from '../shared/protocol/ipc.types.js';
/**
* IPC client for communicating with the TSPM daemon
*/
export class TspmIpcClient {
private ipcClient: plugins.smartipc.IpcClient | null = null;
private socketPath: string;
private daemonPidFile: string;
private isConnected: boolean = false;
// Store event handlers for cleanup
private heartbeatTimeoutHandler?: () => void;
private markDisconnectedHandler?: () => void;
constructor() {
this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock');
this.daemonPidFile = plugins.path.join(paths.tspmDir, 'daemon.pid');
}
/**
* Connect to the daemon, starting it if necessary
*/
public async connect(): Promise<void> {
// Check if already connected
if (this.isConnected && this.ipcClient) {
return;
}
// Check if daemon is running
const daemonRunning = await this.isDaemonRunning();
if (!daemonRunning) {
throw new Error(
'TSPM daemon is not running.\n\n' +
'To start the daemon, run one of:\n' +
' tspm daemon start - Start daemon for this session\n' +
' tspm enable - Enable daemon as system service (recommended)\n',
);
}
// Create IPC client
const uniqueClientId = `cli-${process.pid}-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
this.ipcClient = plugins.smartipc.SmartIpc.createClient({
id: 'tspm-cli',
socketPath: this.socketPath,
clientId: uniqueClientId,
clientOnly: true,
connectRetry: {
enabled: true,
initialDelay: 100,
maxDelay: 2000,
maxAttempts: 30,
totalTimeout: 15000,
},
registerTimeoutMs: 15000,
heartbeat: true,
heartbeatInterval: 5000,
heartbeatTimeout: 20000,
heartbeatInitialGracePeriodMs: 10000,
heartbeatThrowOnTimeout: false, // Don't throw, emit events instead
});
// Connect to the daemon
try {
await this.ipcClient.connect({ waitForReady: true });
this.isConnected = true;
// Handle heartbeat timeouts gracefully
this.heartbeatTimeoutHandler = () => {
console.warn('Heartbeat timeout detected, connection may be degraded');
this.isConnected = false;
};
this.ipcClient.on('heartbeatTimeout', this.heartbeatTimeoutHandler);
// Reflect connection lifecycle on the client state
this.markDisconnectedHandler = () => {
this.isConnected = false;
};
// Common lifecycle events
this.ipcClient.on('disconnect', this.markDisconnectedHandler as any);
this.ipcClient.on('close', this.markDisconnectedHandler as any);
this.ipcClient.on('end', this.markDisconnectedHandler as any);
this.ipcClient.on('error', this.markDisconnectedHandler as any);
// connected
} catch (error) {
// surface meaningful error
throw new Error(
'Could not connect to TSPM daemon. Please try running "tspm daemon start" or "tspm enable".',
);
}
}
/**
* Disconnect from the daemon
*/
public async disconnect(): Promise<void> {
if (this.ipcClient) {
// Remove event listeners before disconnecting
if (this.heartbeatTimeoutHandler) {
this.ipcClient.removeListener('heartbeatTimeout', this.heartbeatTimeoutHandler);
}
if (this.markDisconnectedHandler) {
this.ipcClient.removeListener('disconnect', this.markDisconnectedHandler as any);
this.ipcClient.removeListener('close', this.markDisconnectedHandler as any);
this.ipcClient.removeListener('end', this.markDisconnectedHandler as any);
this.ipcClient.removeListener('error', this.markDisconnectedHandler as any);
}
// Clear handler references
this.heartbeatTimeoutHandler = undefined;
this.markDisconnectedHandler = undefined;
await this.ipcClient.disconnect();
this.ipcClient = null;
this.isConnected = false;
}
}
/**
* Send a request to the daemon
*/
public async request<M extends keyof IpcMethodMap>(
method: M,
params: RequestForMethod<M>,
): Promise<ResponseForMethod<M>> {
if (!this.isConnected || !this.ipcClient) {
// Try to connect first
await this.connect();
}
try {
const response = await this.ipcClient!.request<
RequestForMethod<M>,
ResponseForMethod<M>
>(method, params);
return response;
} catch (error) {
// If the underlying socket disconnected, mark state and surface error
const message = (error as any)?.message || '';
if (
message.includes('Client is not connected') ||
message.includes('ENOTCONN') ||
message.includes('ECONNREFUSED')
) {
this.isConnected = false;
}
throw error;
}
}
/**
* Subscribe to log updates for a specific process
*/
public async subscribe(
processId: ProcessId | number | string,
handler: (log: any) => void,
): Promise<void> {
if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon');
}
const id = toProcessId(processId);
const topic = `logs.${id}`;
// Note: IpcClient.subscribe expects the bare topic (without the 'topic:' prefix)
// and will register a handler for 'topic:<topic>' internally.
await this.ipcClient.subscribe(topic, handler);
}
/**
* Request backlog logs as a stream from the daemon.
* The actual stream will be delivered via the 'stream' event.
*/
public async requestLogsBacklogStream(
processId: ProcessId | number | string,
opts: { lines?: number; sinceTime?: number; types?: Array<'stdout' | 'stderr' | 'system'> } = {},
): Promise<void> {
if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon');
}
const id = toProcessId(processId);
await this.request('logs:subscribe' as any, {
id,
lines: opts.lines,
sinceTime: opts.sinceTime,
types: opts.types,
} as any);
}
/**
* Register a handler for incoming streams (e.g., backlog logs)
*/
public onStream(
handler: (info: any, readable: NodeJS.ReadableStream) => void,
): void {
if (!this.ipcClient) throw new Error('Not connected to daemon');
// smartipc emits 'stream' with (info, readable)
(this.ipcClient as any).on('stream', handler);
}
/**
* Register a temporary handler for backlog topic messages for a specific process
*/
public onBacklogTopic(
processId: ProcessId | number | string,
handler: (log: any) => void,
): () => void {
if (!this.ipcClient) throw new Error('Not connected to daemon');
const id = toProcessId(processId);
const topicType = `topic:logs.backlog.${id}`;
(this.ipcClient as any).onMessage(topicType, handler);
return () => {
try {
(this.ipcClient as any).messageHandlers?.delete?.(topicType);
} catch {}
};
}
/**
* Unsubscribe from log updates for a specific process
*/
public async unsubscribe(processId: ProcessId | number | string): Promise<void> {
if (!this.ipcClient || !this.isConnected) {
throw new Error('Not connected to daemon');
}
const id = toProcessId(processId);
const topic = `logs.${id}`;
// Pass bare topic; client handles 'topic:' prefix internally
await this.ipcClient.unsubscribe(topic);
}
/**
* Check if the daemon is running
*/
private async isDaemonRunning(): Promise<boolean> {
try {
const fs = await import('fs');
// Check if PID file exists
try {
const pidContent = await fs.promises.readFile(
this.daemonPidFile,
'utf-8',
);
const pid = parseInt(pidContent.trim(), 10);
// Check if process is running
try {
process.kill(pid, 0);
// PID is alive, daemon is running
// Socket check is advisory only - the connect retry will handle transient socket issues
try {
await fs.promises.access(this.socketPath);
} catch {
// Socket might be missing temporarily, but daemon is alive
// Let the connection retry logic handle this
}
return true;
} catch {
// Process doesn't exist, clean up stale PID file
await fs.promises.unlink(this.daemonPidFile).catch(() => {});
return false;
}
} catch {
// PID file doesn't exist
return false;
}
} catch {
return false;
}
}
/**
* Stop the daemon
*/
public async stopDaemon(graceful: boolean = true): Promise<void> {
if (!(await this.isDaemonRunning())) {
console.log('Daemon is not running');
return;
}
try {
await this.connect();
await this.request('daemon:shutdown', {
graceful,
timeout: 10000,
});
console.log('Daemon shutdown initiated');
// Wait for daemon to actually stop
const maxWaitTime = 15000; // 15 seconds
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
if (!(await this.isDaemonRunning())) {
console.log('Daemon stopped successfully');
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
console.warn(
'Daemon did not stop within timeout, it may still be running',
);
} catch (error) {
console.error('Error stopping daemon:', error);
// Try to kill the process directly if graceful shutdown failed
try {
const fs = await import('fs');
const pidContent = await fs.promises.readFile(
this.daemonPidFile,
'utf-8',
);
const pid = parseInt(pidContent.trim(), 10);
process.kill(pid, 'SIGKILL');
console.log('Force killed daemon process');
} catch {
console.error('Could not force kill daemon');
}
}
}
/**
* Get daemon status
*/
public async getDaemonStatus(): Promise<ResponseForMethod<'daemon:status'> | null> {
try {
if (!(await this.isDaemonRunning())) {
return null;
}
await this.connect();
return await this.request('daemon:status', {});
} catch (error) {
console.error('Error getting daemon status:', error);
return null;
}
}
}
// Singleton instance
export const tspmIpcClient = new TspmIpcClient();