Compare commits

...

6 Commits

Author SHA1 Message Date
6e39b1db8f 5.8.0
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-31 08:08:27 +00:00
ee4532221a feat(core): Add core TypeScript TSPM implementation: CLI, daemon, client, process management and tests 2025-08-31 08:08:27 +00:00
e39173a827 5.7.0
Some checks failed
Default (tags) / security (push) Failing after 13m15s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-08-31 08:06:03 +00:00
6f14033d9b feat(cli): Add stats CLI command and daemon stats aggregation; fix process manager & wrapper state handling 2025-08-31 08:06:03 +00:00
1c4ffbb612 5.6.2
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 12m37s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-08-31 07:45:48 +00:00
0a75c4cf76 fix(processmanager): Improve process lifecycle handling and cleanup in daemon, monitors and wrappers 2025-08-31 07:45:47 +00:00
11 changed files with 232 additions and 39 deletions

View File

@@ -1,5 +1,33 @@
# Changelog
## 2025-08-31 - 5.8.0 - feat(core)
Add core TypeScript TSPM implementation: CLI, daemon, client, process management and tests
- Add CLI entrypoint and command set (start/stop/add/list/logs/daemon/service/stats/reset and batch ops)
- Add daemon implementation with ProcessManager, ProcessMonitor, ProcessWrapper, LogPersistence and config storage
- Add IPC client (tspmIpcClient) and TspmServiceManager for systemd integration using smartipc/smartdaemon
- Introduce shared protocol types, process ID helpers and standardized error codes for stable IPC
- Include tests and test assets for daemon, integration and IPC client scenarios
- Add README and package metadata (package.json, npmextra.json, commitinfo)
## 2025-08-31 - 5.7.0 - feat(cli)
Add 'stats' CLI command and daemon stats aggregation; fix process manager & wrapper state handling
- Add new 'stats' CLI command to show daemon + process statistics (memory, CPU, uptime, logs in memory, paths, configs) and include it in the default help output
- Implement daemon-side aggregation for logs-in-memory, per-process log counts/bytes, and expose tspmDir/socket/pidFile and config counts in daemon:status
- Enhance startById handler to detect already-running monitors and return current status/pid instead of attempting to restart
- Improve ProcessManager start/restart/stop behavior: if an existing monitor exists but is not running, restart it; ensure PID and status are updated consistently (clear PID on stop)
- Fix ProcessWrapper lifecycle handling: clear internal process reference on exit, improve isRunning() and getPid() semantics to reflect actual runtime state
- Update IPC types to include optional metadata fields (paths, configs, logsInMemory) in DaemonStatusResponse
## 2025-08-31 - 5.6.2 - fix(processmanager)
Improve process lifecycle handling and cleanup in daemon, monitors and wrappers
- StartAll: when a monitor exists but is not running, restart it instead of skipping — ensures saved processes are reliably brought online.
- ProcessMonitor.stop: cancel any pending restart timers to prevent stray restarts after explicit stop.
- ProcessWrapper: add killProcessTree helper and use it for graceful (SIGTERM) and force (SIGKILL) shutdowns to reliably signal child processes.
- Daemon stopAll: yield briefly after stopping processes and inspect monitors (not only processInfo) to accurately report stopped vs failed processes.
## 2025-08-31 - 5.6.1 - fix(daemon)
Ensure robust process shutdown and improve logs/subscriber diagnostics

View File

@@ -1,6 +1,6 @@
{
"name": "@git.zone/tspm",
"version": "5.6.1",
"version": "5.8.0",
"private": false,
"description": "a no fuzz process manager",
"main": "dist_ts/index.js",

View File

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

View File

@@ -39,6 +39,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
);
console.log(' daemon stop Stop the daemon');
console.log(' daemon status Show daemon status');
console.log(' stats Show daemon + process stats');
console.log(
'\nUse tspm [command] --help for more information about a command.',
);

66
ts/cli/commands/stats.ts Normal file
View File

@@ -0,0 +1,66 @@
import * as plugins from '../plugins.js';
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
import type { CliArguments } from '../types.js';
import { registerIpcCommand } from '../registration/index.js';
import { pad } from '../helpers/formatting.js';
import { formatMemory } from '../helpers/memory.js';
export function registerStatsCommand(smartcli: plugins.smartcli.Smartcli) {
registerIpcCommand(
smartcli,
'stats',
async (_argvArg: CliArguments) => {
// Daemon status
const status = await tspmIpcClient.request('daemon:status', {});
console.log('TSPM Daemon:');
console.log('─'.repeat(60));
console.log(`Version: ${status.version || 'unknown'}`);
console.log(`PID: ${status.pid}`);
console.log(`Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`);
console.log(`Processes: ${status.processCount}`);
if (typeof status.memoryUsage === 'number') {
console.log(`Memory: ${formatMemory(status.memoryUsage)}`);
}
if (typeof status.cpuUsage === 'number') {
console.log(`CPU (user): ${status.cpuUsage.toFixed(3)}s`);
}
if ((status as any).paths) {
const pathsInfo = (status as any).paths as { tspmDir?: string; socketPath?: string; pidFile?: string };
console.log(`tspmDir: ${pathsInfo.tspmDir || '-'}`);
console.log(`Socket: ${pathsInfo.socketPath || '-'}`);
console.log(`PID File: ${pathsInfo.pidFile || '-'}`);
}
if ((status as any).configs) {
const cfg = (status as any).configs as { processConfigs?: number };
console.log(`Configs: ${cfg.processConfigs ?? 0}`);
}
if ((status as any).logsInMemory) {
const lm = (status as any).logsInMemory as { totalCount: number; totalBytes: number };
console.log(`Logs (mem): ${lm.totalCount} entries, ${formatMemory(lm.totalBytes)}`);
}
console.log('');
// Process list (reuse list view with CPU column)
const response = await tspmIpcClient.request('list', {});
const processes = response.processes;
console.log('Process List:');
console.log('┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐');
console.log('│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │');
console.log('├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤');
for (const proc of processes) {
const statusColor =
proc.status === 'online' ? '\x1b[32m' : proc.status === 'errored' ? '\x1b[31m' : '\x1b[33m';
const resetColor = '\x1b[0m';
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu) ? `${proc.cpu.toFixed(1)}%` : '-';
const nameDisplay = String(proc.id); // name not carried in IProcessInfo
console.log(
`${pad(String(proc.id), 7)}${pad(nameDisplay, 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)}${pad(cpuStr, 8)}${pad(proc.restarts.toString(), 8)}`,
);
}
console.log('└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘');
},
{ actionLabel: 'get daemon stats' },
);
}

View File

@@ -20,6 +20,7 @@ import { registerStartAllCommand } from './commands/batch/start-all.js';
import { registerStopAllCommand } from './commands/batch/stop-all.js';
import { registerRestartAllCommand } from './commands/batch/restart-all.js';
import { registerDaemonCommand } from './commands/daemon/index.js';
import { registerStatsCommand } from './commands/stats.js';
import { registerEnableCommand } from './commands/service/enable.js';
import { registerDisableCommand } from './commands/service/disable.js';
import { registerResetCommand } from './commands/reset.js';
@@ -117,6 +118,7 @@ export const run = async (): Promise<void> => {
// Daemon commands
registerDaemonCommand(smartcliInstance);
registerStatsCommand(smartcliInstance);
// Service commands
registerEnableCommand(smartcliInstance);

View File

@@ -95,6 +95,16 @@ export class ProcessManager extends EventEmitter {
// Check if process with this id already exists
if (this.processes.has(config.id)) {
const existing = this.processes.get(config.id)!;
// If an existing monitor is present but not running, treat this as a fresh start via restart logic
if (!existing.isRunning()) {
this.logger.info(
`Existing monitor found for id '${config.id}' but not running. Restarting it...`,
);
await this.restart(config.id);
return;
}
// Already running surface a meaningful error
throw new ValidationError(
`Process with id '${config.id}' already exists`,
'ERR_DUPLICATE_PROCESS',
@@ -246,7 +256,8 @@ export class ProcessManager extends EventEmitter {
try {
await monitor.stop();
this.updateProcessInfo(id, { status: 'stopped' });
// Ensure status and PID are reflected immediately
this.updateProcessInfo(id, { status: 'stopped', pid: undefined });
this.logger.info(`Successfully stopped process with id '${id}'`);
} catch (error: Error | unknown) {
const processError = new ProcessError(
@@ -430,6 +441,8 @@ export class ProcessManager extends EventEmitter {
const pid = monitor.getPid();
if (pid) {
info.pid = pid;
} else {
info.pid = undefined;
}
// Update uptime if available
@@ -449,9 +462,7 @@ export class ProcessManager extends EventEmitter {
info.restarts = monitor.getRestartCount();
// Update status based on actual running state
if (monitor.isRunning()) {
info.status = 'online';
}
info.status = monitor.isRunning() ? 'online' : 'stopped';
}
}
@@ -499,8 +510,12 @@ export class ProcessManager extends EventEmitter {
*/
public async startAll(): Promise<void> {
for (const [id, config] of this.processConfigs.entries()) {
if (!this.processes.has(id)) {
const monitor = this.processes.get(id);
if (!monitor) {
await this.start(config);
} else if (!monitor.isRunning()) {
// If a monitor exists but is not running, restart the process to ensure a clean start
await this.restart(id);
}
}
}

View File

@@ -400,6 +400,11 @@ export class ProcessMonitor extends EventEmitter {
if (this.intervalId) {
clearInterval(this.intervalId);
}
// Cancel any pending restart timer
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
if (this.processWrapper) {
// Clear pidusage state for current PID before stopping to avoid leaks
try {

View File

@@ -24,6 +24,26 @@ export class ProcessWrapper extends EventEmitter {
private stdoutRemainder: string = '';
private stderrRemainder: string = '';
// Helper: send a signal to the process and all its children (best-effort)
private async killProcessTree(signal: NodeJS.Signals): Promise<void> {
if (!this.process || !this.process.pid) return;
const rootPid = this.process.pid;
await new Promise<void>((resolve) => {
plugins.psTree(rootPid, (err: any, children: ReadonlyArray<{ PID: string }>) => {
const pids: number[] = [rootPid, ...children.map((c) => Number(c.PID)).filter((n) => Number.isFinite(n))];
for (const pid of pids) {
try {
// Always signal individual PIDs to avoid accidentally targeting unrelated groups
process.kill(pid, signal);
} catch {
// ignore ESRCH/EPERM
}
}
resolve();
});
});
}
constructor(options: IProcessWrapperOptions) {
super();
this.options = options;
@@ -73,6 +93,9 @@ export class ProcessWrapper extends EventEmitter {
this.stdoutRemainder = '';
this.stderrRemainder = '';
// Mark process reference as gone so isRunning() reflects reality
this.process = null;
this.emit('exit', code, signal);
});
@@ -193,17 +216,13 @@ export class ProcessWrapper extends EventEmitter {
// First try SIGTERM for graceful shutdown
if (this.process.pid) {
try {
this.logger.debug(`Sending SIGTERM to process ${this.process.pid}`);
try {
// Try to signal the whole process group on POSIX to ensure children get the signal too
if (process.platform !== 'win32') {
process.kill(-Math.abs(this.process.pid), 'SIGTERM');
} else {
process.kill(this.process.pid, 'SIGTERM');
}
} catch {
// Fallback to direct process kill if group kill fails
process.kill(this.process.pid, 'SIGTERM');
this.logger.debug(`Sending SIGTERM to process tree rooted at ${this.process.pid}`);
await this.killProcessTree('SIGTERM');
// If the process already exited, return immediately
if (typeof this.process.exitCode === 'number') {
this.logger.debug('Process already exited, no need to wait');
return;
}
// Wait for exit or escalate
@@ -218,26 +237,15 @@ export class ProcessWrapper extends EventEmitter {
const onExit = () => cleanup();
this.process!.once('exit', onExit);
const killTimer = setTimeout(() => {
const killTimer = setTimeout(async () => {
if (!this.process || !this.process.pid) return cleanup();
this.logger.warn(
`Process ${this.process.pid} did not exit gracefully, force killing...`,
);
this.addSystemLog(
'Process did not exit gracefully, force killing...',
`Process ${this.process.pid} did not exit gracefully, force killing tree...`,
);
this.addSystemLog('Process did not exit gracefully, force killing...');
try {
if (process.platform !== 'win32') {
process.kill(-Math.abs(this.process.pid), 'SIGKILL');
} else {
process.kill(this.process.pid, 'SIGKILL');
}
} catch (error: any) {
this.logger.debug(
`Failed to send SIGKILL, process probably already exited: ${error?.message || String(error)}`,
);
}
await this.killProcessTree('SIGKILL');
} catch {}
// Give a short grace period after SIGKILL
setTimeout(() => cleanup(), 500);
}, 5000);
@@ -264,6 +272,7 @@ export class ProcessWrapper extends EventEmitter {
* Get the process ID if running
*/
public getPid(): number | null {
if (!this.isRunning()) return null;
return this.process?.pid || null;
}
@@ -287,7 +296,13 @@ export class ProcessWrapper extends EventEmitter {
* Check if the process is currently running
*/
public isRunning(): boolean {
return this.process !== null && typeof this.process.exitCode !== 'number';
if (!this.process) return false;
// In Node, while the child is running: exitCode === null and signalCode === null/undefined
// After it exits: exitCode is a number OR signalCode is a string
const anyProc: any = this.process as any;
const exitCode = anyProc.exitCode;
const signalCode = anyProc.signalCode;
return exitCode === null && (signalCode === null || typeof signalCode === 'undefined');
}
/**

View File

@@ -10,6 +10,7 @@ import type {
DaemonStatusResponse,
HeartbeatResponse,
} from '../shared/protocol/ipc.types.js';
import { LogPersistence } from './logpersistence.js';
/**
* Central daemon server that manages all TSPM processes
@@ -170,7 +171,22 @@ export class TspmDaemon {
throw new Error(`Process ${id} not found`);
}
await this.tspmInstance.setDesiredState(id, 'online');
const existing = this.tspmInstance.processes.get(id);
if (existing) {
if (existing.isRunning()) {
// Already running; return current status/pid
const runningInfo = this.tspmInstance.processInfo.get(id);
return {
processId: id,
pid: runningInfo?.pid,
status: runningInfo?.status || 'online',
};
} else {
await this.tspmInstance.restart(id);
}
} else {
await this.tspmInstance.start(config);
}
const processInfo = this.tspmInstance.processInfo.get(id);
return {
processId: id,
@@ -450,10 +466,12 @@ export class TspmDaemon {
await this.tspmInstance.setDesiredStateForAll('stopped');
await this.tspmInstance.stopAll();
// Yield briefly to allow any pending exit events to settle
await new Promise((r) => setTimeout(r, 50));
// Get status of all processes
for (const [id, processInfo] of this.tspmInstance.processInfo) {
if (processInfo.status === 'stopped') {
// Determine which monitors are no longer running
for (const [id, monitor] of this.tspmInstance.processes) {
if (!monitor.isRunning()) {
stopped.push(id);
} else {
failed.push({ id, error: 'Failed to stop' });
@@ -499,6 +517,28 @@ export class TspmDaemon {
'daemon:status',
async (request: RequestForMethod<'daemon:status'>) => {
const memUsage = process.memoryUsage();
// Aggregate log stats from monitors
let totalLogCount = 0;
let totalLogBytes = 0;
const perProcess: Array<{ id: ProcessId; count: number; bytes: number }> = [];
for (const [id, monitor] of this.tspmInstance.processes.entries()) {
try {
const logs = monitor.getLogs();
const count = logs.length;
const bytes = LogPersistence.calculateLogMemorySize(logs);
totalLogCount += count;
totalLogBytes += bytes;
perProcess.push({ id, count, bytes });
} catch {}
}
const pathsInfo = {
tspmDir: paths.tspmDir,
socketPath: this.socketPath,
pidFile: this.daemonPidFile,
};
const configsInfo = {
processConfigs: this.tspmInstance.processConfigs.size,
};
return {
status: 'running',
pid: process.pid,
@@ -507,6 +547,13 @@ export class TspmDaemon {
memoryUsage: memUsage.heapUsed,
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
version: this.version,
logsInMemory: {
totalCount: totalLogCount,
totalBytes: totalLogBytes,
perProcess,
},
paths: pathsInfo,
configs: configsInfo,
};
},
);

View File

@@ -228,6 +228,20 @@ export interface DaemonStatusResponse {
memoryUsage?: number;
cpuUsage?: number;
version?: string;
// Additional metadata (optional)
paths?: {
tspmDir?: string;
socketPath?: string;
pidFile?: string;
};
configs?: {
processConfigs?: number;
};
logsInMemory?: {
totalCount: number;
totalBytes: number;
perProcess: Array<{ id: ProcessId; count: number; bytes: number }>;
};
}
// Daemon shutdown command