BREAKING CHANGE(daemon): Refactor daemon lifecycle and service management: remove IPC auto-spawn, add TspmServiceManager and CLI enable/disable

This commit is contained in:
2025-08-28 10:39:35 +00:00
parent 35b6a6a8d0
commit d33a001edc
8 changed files with 291 additions and 133 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 2025-08-28 - 2.0.0 - BREAKING CHANGE(daemon)
Refactor daemon lifecycle and service management: remove IPC auto-spawn, add TspmServiceManager and CLI enable/disable
- Do not auto-spawn the daemon from the IPC client anymore — attempts to connect will now error with instructions to start the daemon manually or enable the system service (breaking change).
- Add TspmServiceManager to manage the daemon as a systemd service via smartdaemon (enable/disable/reload/status helpers).
- CLI: add 'enable' and 'disable' commands to install/uninstall the daemon as a system service and add 'daemon start-service' entrypoint used by systemd.
- CLI: improve error handling and user hints when the daemon is not running (suggests `tspm daemon start` or `tspm enable`).
- IPC client: removed startDaemon() and related auto-reconnect/start logic; request() no longer auto-reconnects or implicitly start the daemon.
- Export TspmServiceManager from the package index so service management is part of the public API.
- Updated development plan/readme (readme.plan.md) to reflect the refactor toward proper SmartDaemon integration and migration notes.
## 2025-08-26 - 1.8.0 - feat(daemon)
Add real-time log streaming and pub/sub: daemon publishes per-process logs, IPC client subscribe/unsubscribe, CLI --follow streaming, and sequencing for logs

0
cli.js Normal file → Executable file
View File

View File

@@ -1,56 +1,48 @@
# TSPM Real-Time Log Streaming Implementation Plan
# TSPM SmartDaemon Service Management Refactor
## Overview
Implementing real-time log streaming (tailing) functionality for TSPM using SmartIPC's pub/sub capabilities.
## Problem
Currently TSPM auto-spawns the daemon as a detached child process, which is improper daemon management. It should use smartdaemon for all lifecycle management and never spawn processes directly.
## Approach: Hybrid Request + Subscribe
1. Initial getLogs request to fetch historical logs up to current point
2. Subscribe to pub/sub channel for real-time updates
3. Use sequence numbers to detect and handle gaps/duplicates
4. Per-process topics for granular subscriptions
## Solution
Refactor to use SmartDaemon for proper systemd service integration.
## Implementation Tasks
### Core Changes
- [x] Update IProcessLog interface with seq and runId fields
- [x] Add nextSeq and runId fields to ProcessWrapper class
- [x] Update addLog() methods to include sequencing
- [x] Implement pub/sub publishing in daemon
### Phase 1: Remove Auto-Spawn Behavior
- [x] Remove spawn import from ts/classes.ipcclient.ts
- [x] Delete startDaemon() method from IpcClient
- [x] Update connect() to throw error when daemon not running
- [x] Remove auto-reconnect logic from request() method
### IPC Client Updates
- [x] Add subscribe/unsubscribe methods to TspmIpcClient
- [ ] Implement log streaming handler
- [ ] Add connection state management for subscriptions
### Phase 2: Create Service Manager
- [x] Create new file ts/classes.servicemanager.ts
- [x] Implement TspmServiceManager class
- [x] Add getOrCreateService() method
- [x] Add enableService() method
- [x] Add disableService() method
- [x] Add getServiceStatus() method
### CLI Enhancement
- [x] Add --follow flag to logs command
- [x] Implement streaming output with proper formatting
- [x] Handle Ctrl+C gracefully to unsubscribe
### Phase 3: Update CLI Commands
- [x] Add 'enable' command to CLI
- [x] Add 'disable' command to CLI
- [x] Update 'daemon start' to work without systemd
- [x] Add 'daemon start-service' internal command for systemd
- [x] Update all commands to handle missing daemon gracefully
- [x] Add proper error messages with hints
### Reliability Features
- [x] Add backpressure handling (drop oldest when buffer full)
- [x] Implement gap detection and recovery
- [x] Add process restart detection via runId
### Phase 4: Update Documentation
- [x] Update help text in CLI
- [ ] Update command descriptions
- [x] Add service management section
### Testing
- [x] Test basic log streaming
- [x] Test gap recovery
- [x] Test high-volume logging scenarios
- [x] Test process restart handling
### Phase 5: Testing
- [x] Test enable command
- [x] Test disable command
- [x] Test daemon commands
- [x] Test error handling when daemon not running
- [x] Build and verify TypeScript compilation
## Technical Details
### Sequence Numbering
- Each log entry gets incrementing seq number per process
- runId changes on process restart
- Client tracks lastSeq to detect gaps
### Topic Structure
- Format: `logs.<processId>`
- Daemon publishes to topic on new log entries
- Clients subscribe to specific process topics
### Backpressure Strategy
- Circular buffer of 10,000 entries per process
- Drop oldest entries when buffer full
- Client can detect gaps via sequence numbers
## Migration Notes
- Users will need to run `tspm enable` once after update
- Existing daemon instances will stop working
- Documentation needs updating to explain new behavior

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tspm',
version: '1.8.0',
version: '2.0.0',
description: 'a no fuzz process manager'
}

View File

@@ -1,6 +1,6 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { spawn } from 'child_process';
import type {
IpcMethodMap,
RequestForMethod,
@@ -34,10 +34,12 @@ export class TspmIpcClient {
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));
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
@@ -75,7 +77,7 @@ export class TspmIpcClient {
} 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.',
'Could not connect to TSPM daemon. Please try running "tspm daemon start" or "tspm enable".',
);
}
}
@@ -99,7 +101,10 @@ export class TspmIpcClient {
params: RequestForMethod<M>,
): Promise<ResponseForMethod<M>> {
if (!this.isConnected || !this.ipcClient) {
await this.connect();
throw new Error(
'Not connected to TSPM daemon.\n' +
'Run "tspm daemon start" or "tspm enable" first.'
);
}
try {
@@ -110,22 +115,7 @@ export class TspmIpcClient {
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);
}
// Don't try to auto-reconnect, just throw the error
throw error;
}
}
@@ -195,41 +185,7 @@ export class TspmIpcClient {
}
}
/**
* 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 using SmartIPC's helper
try {
await plugins.smartipc.SmartIpc.waitForServer({
socketPath: this.socketPath,
timeoutMs: 15000,
});
} catch (error) {
throw new Error(`Daemon failed to start: ${error.message}`);
}
}
/**
* Stop the daemon

View File

@@ -0,0 +1,103 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
/**
* Manages TSPM daemon as a systemd service via smartdaemon
*/
export class TspmServiceManager {
private smartDaemon: plugins.smartdaemon.SmartDaemon;
private service: any = null; // SmartDaemonService type is not exported
constructor() {
this.smartDaemon = new plugins.smartdaemon.SmartDaemon();
}
/**
* Get or create the TSPM daemon service configuration
*/
private async getOrCreateService(): Promise<any> {
if (!this.service) {
const cliPath = plugins.path.join(paths.packageDir, 'cli.js');
// Create service configuration
this.service = await this.smartDaemon.addService({
name: 'tspm-daemon',
description: 'TSPM Process Manager Daemon',
command: `${process.execPath} ${cliPath} daemon start-service`,
workingDir: process.env.HOME || process.cwd(),
version: '1.0.0'
});
}
return this.service;
}
/**
* Enable the TSPM daemon as a system service
*/
public async enableService(): Promise<void> {
const service = await this.getOrCreateService();
// Save service configuration
await service.save();
// Enable service to start on boot
await service.enable();
// Start the service immediately
await service.start();
}
/**
* Disable the TSPM daemon service
*/
public async disableService(): Promise<void> {
const service = await this.getOrCreateService();
// Stop the service if running
try {
await service.stop();
} catch (error) {
// Service might not be running
console.log('Service was not running');
}
// Disable service from starting on boot
await service.disable();
}
/**
* Get the current status of the systemd service
*/
public async getServiceStatus(): Promise<{
enabled: boolean;
running: boolean;
status: string;
}> {
try {
await this.getOrCreateService();
// Note: SmartDaemon doesn't provide direct status methods,
// so we'll need to check via systemctl commands
// This is a simplified implementation
return {
enabled: true, // Would need to check systemctl is-enabled
running: true, // Would need to check systemctl is-active
status: 'active'
};
} catch (error) {
return {
enabled: false,
running: false,
status: 'inactive'
};
}
}
/**
* Reload the systemd service configuration
*/
public async reloadService(): Promise<void> {
const service = await this.getOrCreateService();
await service.reload();
}
}

153
ts/cli.ts
View File

@@ -1,6 +1,7 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { tspmIpcClient } from './classes.ipcclient.js';
import { TspmServiceManager } from './classes.servicemanager.js';
import { Logger, LogLevel } from './utils.errorhandler.js';
import type { IProcessConfig } from './classes.tspm.js';
@@ -51,6 +52,21 @@ function formatMemory(bytes: number): string {
}
}
// Helper function to handle daemon connection errors
function handleDaemonError(error: any, action: string): void {
if (error.message?.includes('daemon is not running') ||
error.message?.includes('Not connected') ||
error.message?.includes('ECONNREFUSED')) {
console.error(`Error: Cannot ${action} - TSPM daemon is not running.`);
console.log('\nTo start the daemon, run one of:');
console.log(' tspm daemon start - Start for this session only');
console.log(' tspm enable - Enable as system service (recommended)');
} else {
console.error(`Error ${action}:`, error.message);
}
process.exit(1);
}
// Helper function for padding strings
function pad(str: string, length: number): string {
return str.length > length
@@ -79,7 +95,10 @@ export const run = async (): Promise<void> => {
`TSPM - TypeScript Process Manager v${tspmProjectinfo.npm.version}`,
);
console.log('Usage: tspm [command] [options]');
console.log('\nCommands:');
console.log('\nService Management:');
console.log(' enable Enable TSPM as system service (systemd)');
console.log(' disable Disable TSPM system service');
console.log('\nProcess Commands:');
console.log(' start <script> Start a process');
console.log(' list List all processes');
console.log(' stop <id> Stop a process');
@@ -91,8 +110,8 @@ export const run = async (): Promise<void> => {
console.log(' stop-all Stop all processes');
console.log(' restart-all Restart all processes');
console.log('\nDaemon Commands:');
console.log(' daemon start Start the TSPM daemon');
console.log(' daemon stop Stop the TSPM daemon');
console.log(' daemon start Start daemon manually (current session)');
console.log(' daemon stop Stop the daemon');
console.log(' daemon status Show daemon status');
console.log(
'\nUse tspm [command] --help for more information about a command.',
@@ -139,9 +158,10 @@ export const run = async (): Promise<void> => {
);
}
} catch (error) {
console.error(
'Error: Could not connect to TSPM daemon. Use "tspm daemon start" to start it.',
);
console.error('Error: TSPM daemon is not running.');
console.log('\nTo start the daemon, run one of:');
console.log(' tspm daemon start - Start for this session only');
console.log(' tspm enable - Enable as system service (recommended)');
}
},
error: (err) => {
@@ -217,8 +237,7 @@ export const run = async (): Promise<void> => {
console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`);
} catch (error) {
console.error('Error starting process:', error.message);
process.exit(1);
handleDaemonError(error, 'start process');
}
},
error: (err) => {
@@ -247,8 +266,7 @@ export const run = async (): Promise<void> => {
console.error(`✗ Failed to stop process: ${response.message}`);
}
} catch (error) {
console.error('Error stopping process:', error.message);
process.exit(1);
handleDaemonError(error, 'stop process');
}
},
error: (err) => {
@@ -276,8 +294,7 @@ export const run = async (): Promise<void> => {
console.log(` PID: ${response.pid || 'N/A'}`);
console.log(` Status: ${response.status}`);
} catch (error) {
console.error('Error restarting process:', error.message);
process.exit(1);
handleDaemonError(error, 'restart process');
}
},
error: (err) => {
@@ -306,8 +323,7 @@ export const run = async (): Promise<void> => {
console.error(`✗ Failed to delete process: ${response.message}`);
}
} catch (error) {
console.error('Error deleting process:', error.message);
process.exit(1);
handleDaemonError(error, 'delete process');
}
},
error: (err) => {
@@ -356,8 +372,7 @@ export const run = async (): Promise<void> => {
);
}
} catch (error) {
console.error('Error listing processes:', error.message);
process.exit(1);
handleDaemonError(error, 'list processes');
}
},
error: (err) => {
@@ -409,8 +424,7 @@ export const run = async (): Promise<void> => {
}
}
} catch (error) {
console.error('Error describing process:', error.message);
process.exit(1);
handleDaemonError(error, 'describe process');
}
},
error: (err) => {
@@ -506,8 +520,7 @@ export const run = async (): Promise<void> => {
await new Promise(() => {}); // Block forever until interrupted
}
} catch (error) {
console.error('Error getting logs:', error.message);
process.exit(1);
handleDaemonError(error, 'get logs');
}
},
error: (err) => {
@@ -537,8 +550,7 @@ export const run = async (): Promise<void> => {
}
}
} catch (error) {
console.error('Error starting all processes:', error.message);
process.exit(1);
handleDaemonError(error, 'start all processes');
}
},
error: (err) => {
@@ -568,8 +580,7 @@ export const run = async (): Promise<void> => {
}
}
} catch (error) {
console.error('Error stopping all processes:', error.message);
process.exit(1);
handleDaemonError(error, 'stop all processes');
}
},
error: (err) => {
@@ -601,8 +612,7 @@ export const run = async (): Promise<void> => {
}
}
} catch (error) {
console.error('Error restarting all processes:', error.message);
process.exit(1);
handleDaemonError(error, 'restart all processes');
}
},
error: (err) => {
@@ -630,19 +640,49 @@ export const run = async (): Promise<void> => {
return;
}
console.log('Starting TSPM daemon...');
await tspmIpcClient.connect();
console.log('✓ TSPM daemon started successfully');
console.log('Starting TSPM daemon manually...');
// Import spawn to start daemon process
const { spawn } = await import('child_process');
const daemonScript = plugins.path.join(
paths.packageDir,
'dist_ts',
'daemon.js',
);
// Start daemon as a regular background process (not detached)
const daemonProcess = spawn(process.execPath, [daemonScript], {
stdio: 'ignore',
env: {
...process.env,
TSPM_DAEMON_MODE: 'true',
},
});
console.log(`Started daemon with PID: ${daemonProcess.pid}`);
// Wait for daemon to be ready
await new Promise(resolve => setTimeout(resolve, 2000));
const newStatus = await tspmIpcClient.getDaemonStatus();
if (newStatus) {
console.log('✓ TSPM daemon started successfully');
console.log(` PID: ${newStatus.pid}`);
console.log('\nNote: This daemon will run until you stop it or logout.');
console.log('For automatic startup, use "tspm enable" instead.');
}
} catch (error) {
console.error('Error starting daemon:', error.message);
process.exit(1);
}
break;
case 'start-service':
// This is called by systemd - start the daemon directly
console.log('Starting TSPM daemon for systemd service...');
const { startDaemon } = await import('./classes.daemon.js');
await startDaemon();
break;
case 'stop':
try {
@@ -676,6 +716,9 @@ export const run = async (): Promise<void> => {
`Memory: ${formatMemory(status.memoryUsage || 0)}`,
);
console.log(`CPU: ${status.cpuUsage?.toFixed(1) || 0}s`);
// Disconnect from daemon after getting status
await tspmIpcClient.disconnect();
} catch (error) {
console.error('Error getting daemon status:', error.message);
process.exit(1);
@@ -697,6 +740,58 @@ export const run = async (): Promise<void> => {
complete: () => {},
});
// Enable command - Enable TSPM daemon as systemd service
smartcliInstance.addCommand('enable').subscribe({
next: async (argvArg: CliArguments) => {
try {
const serviceManager = new TspmServiceManager();
console.log('Enabling TSPM daemon as system service...');
await serviceManager.enableService();
console.log('✓ TSPM daemon enabled and started as system service');
console.log(' The daemon will now start automatically on system boot');
console.log(' Use "tspm disable" to remove the service');
} catch (error) {
console.error('Error enabling service:', error.message);
if (error.message.includes('permission') || error.message.includes('denied')) {
console.log('\nNote: You may need to run this command with sudo');
}
process.exit(1);
}
},
error: (err) => {
cliLogger.error(err);
},
complete: () => {},
});
// Disable command - Disable TSPM daemon systemd service
smartcliInstance.addCommand('disable').subscribe({
next: async (argvArg: CliArguments) => {
try {
const serviceManager = new TspmServiceManager();
console.log('Disabling TSPM daemon service...');
await serviceManager.disableService();
console.log('✓ TSPM daemon service disabled');
console.log(' The daemon will no longer start on system boot');
console.log(' Use "tspm enable" to re-enable the service');
} catch (error) {
console.error('Error disabling service:', error.message);
if (error.message.includes('permission') || error.message.includes('denied')) {
console.log('\nNote: You may need to run this command with sudo');
}
process.exit(1);
}
},
error: (err) => {
cliLogger.error(err);
},
complete: () => {},
});
// Start parsing commands
smartcliInstance.startParse();
};

View File

@@ -2,6 +2,7 @@ export * from './classes.tspm.js';
export * from './classes.processmonitor.js';
export * from './classes.daemon.js';
export * from './classes.ipcclient.js';
export * from './classes.servicemanager.js';
export * from './ipc.types.js';
import * as cli from './cli.js';