262 lines
6.8 KiB
TypeScript
262 lines
6.8 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import * as paths from './paths.js';
|
|
|
|
import type {
|
|
IpcMethodMap,
|
|
RequestForMethod,
|
|
ResponseForMethod,
|
|
} from './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;
|
|
|
|
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
|
|
this.ipcClient = plugins.smartipc.SmartIpc.createClient({
|
|
id: 'tspm-cli',
|
|
socketPath: this.socketPath,
|
|
clientId: `cli-${process.pid}`,
|
|
connectRetry: {
|
|
enabled: true,
|
|
initialDelay: 100,
|
|
maxDelay: 2000,
|
|
maxAttempts: 30,
|
|
totalTimeout: 15000,
|
|
},
|
|
registerTimeoutMs: 8000,
|
|
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.ipcClient.on('heartbeatTimeout', () => {
|
|
console.warn('Heartbeat timeout detected, connection may be degraded');
|
|
this.isConnected = false;
|
|
});
|
|
|
|
console.log('Connected to TSPM daemon');
|
|
} catch (error) {
|
|
console.error('Failed to connect to daemon:', 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) {
|
|
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) {
|
|
// Don't try to auto-reconnect, just throw the error
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to log updates for a specific process
|
|
*/
|
|
public async subscribe(
|
|
processId: string,
|
|
handler: (log: any) => void,
|
|
): Promise<void> {
|
|
if (!this.ipcClient || !this.isConnected) {
|
|
throw new Error('Not connected to daemon');
|
|
}
|
|
|
|
const topic = `logs.${processId}`;
|
|
await this.ipcClient.subscribe(`topic:${topic}`, handler);
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from log updates for a specific process
|
|
*/
|
|
public async unsubscribe(processId: string): Promise<void> {
|
|
if (!this.ipcClient || !this.isConnected) {
|
|
throw new Error('Not connected to daemon');
|
|
}
|
|
|
|
const topic = `logs.${processId}`;
|
|
await this.ipcClient.unsubscribe(`topic:${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();
|