feat(daemon): Add central TSPM daemon and IPC client; refactor CLI to use daemon and improve monitoring/error handling
This commit is contained in:
263
ts/classes.ipcclient.ts
Normal file
263
ts/classes.ipcclient.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { spawn } from 'child_process';
|
||||
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) {
|
||||
console.log('Daemon not running, starting it...');
|
||||
await this.startDaemon();
|
||||
// Wait a bit for daemon to initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Create IPC client
|
||||
this.ipcClient = new plugins.smartipc.IpcClient({
|
||||
id: 'tspm-cli',
|
||||
socketPath: this.socketPath,
|
||||
});
|
||||
|
||||
// Connect to the daemon
|
||||
try {
|
||||
await this.ipcClient.connect();
|
||||
this.isConnected = true;
|
||||
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" manually.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.ipcClient!.request<
|
||||
RequestForMethod<M>,
|
||||
ResponseForMethod<M>
|
||||
>(method, params);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Handle connection errors by trying to reconnect once
|
||||
if (
|
||||
error.message?.includes('ECONNREFUSED') ||
|
||||
error.message?.includes('ENOENT')
|
||||
) {
|
||||
console.log('Connection lost, attempting to reconnect...');
|
||||
this.isConnected = false;
|
||||
await this.connect();
|
||||
|
||||
// Retry the request
|
||||
return await this.ipcClient!.request<
|
||||
RequestForMethod<M>,
|
||||
ResponseForMethod<M>
|
||||
>(method, params);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Also check if socket exists and is accessible
|
||||
try {
|
||||
await fs.promises.access(this.socketPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Socket doesn't exist, daemon might be starting
|
||||
return false;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon process
|
||||
*/
|
||||
private async startDaemon(): Promise<void> {
|
||||
const daemonScript = plugins.path.join(
|
||||
paths.packageDir,
|
||||
'dist_ts',
|
||||
'daemon.js',
|
||||
);
|
||||
|
||||
// Spawn the daemon as a detached process
|
||||
const daemonProcess = spawn(process.execPath, [daemonScript], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
env: {
|
||||
...process.env,
|
||||
TSPM_DAEMON_MODE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
// Unref the process so the parent can exit
|
||||
daemonProcess.unref();
|
||||
|
||||
console.log(`Started daemon process with PID: ${daemonProcess.pid}`);
|
||||
|
||||
// Wait for daemon to be ready (check for socket file)
|
||||
const maxWaitTime = 10000; // 10 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
if (await this.isDaemonRunning()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error('Daemon failed to start within timeout period');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
Reference in New Issue
Block a user