Compare commits

...

4 Commits

Author SHA1 Message Date
8f31672a67 5.6.1
Some checks failed
Default (tags) / security (push) Successful in 51s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-31 00:01:50 +00:00
b3087831e2 fix(daemon): Ensure robust process shutdown and improve logs/subscriber diagnostics 2025-08-31 00:01:50 +00:00
4160b3f031 5.6.0
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 3m57s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 23:36:26 +00:00
fa50ce40c8 feat(processmonitor): Add CPU monitoring and display CPU in process list 2025-08-30 23:36:26 +00:00
10 changed files with 150 additions and 27 deletions

View File

@@ -1,5 +1,23 @@
# Changelog # Changelog
## 2025-08-31 - 5.6.1 - fix(daemon)
Ensure robust process shutdown and improve logs/subscriber diagnostics
- Make ProcessWrapper.stop asynchronous and awaitable to avoid race conditions when stopping processes
- Signal entire process groups on POSIX (kill by negative PID) and fall back to per-PID signalling; escalate to SIGKILL after a timeout
- Await processWrapper.stop() from ProcessMonitor when enforcing memory limits or handling exits/errors to ensure child processes are cleaned up
- Add logs:subscribers IPC endpoint and corresponding types to inspect current subscribers for a process log topic
- Add optional CLI debug output in logs command (enabled via TSPM_DEBUG=true) to print subscriber counts and details
- Support passing request.lines to getLogs handler in daemon to limit returned log entries
## 2025-08-30 - 5.6.0 - feat(processmonitor)
Add CPU monitoring and display CPU in process list
- CLI: show a CPU column in the `tspm list` output (adds formatting and placeholder name display)
- Daemon: ProcessMonitor now collects CPU usage for the process group in addition to memory
- Daemon: ProcessMonitor exposes getLastCpuUsage() and ProcessManager syncs CPU values into IProcessInfo
- Non-breaking: UI and internal stats enriched to surface CPU metrics for processes
## 2025-08-30 - 5.5.0 - feat(logs) ## 2025-08-30 - 5.5.0 - feat(logs)
Improve logs streaming and backlog delivery; add CLI filters and ndjson output Improve logs streaming and backlog delivery; add CLI filters and ndjson output

View File

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

View File

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

View File

@@ -20,13 +20,13 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
console.log('Process List:'); console.log('Process List:');
console.log( console.log(
'┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┐', '┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐',
); );
console.log( console.log(
'│ ID │ Name │ Status │ PID │ Memory │ Restarts │', '│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │',
); );
console.log( console.log(
'├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┤', '├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤',
); );
for (const proc of processes) { for (const proc of processes) {
@@ -38,13 +38,18 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
: '\x1b[33m'; : '\x1b[33m';
const resetColor = '\x1b[0m'; const resetColor = '\x1b[0m';
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu)
? `${proc.cpu.toFixed(1)}%`
: '-';
// Name is not part of IProcessInfo; show ID as placeholder for now
const nameDisplay = String(proc.id);
console.log( console.log(
`${pad(String(proc.id), 7)}${pad(String(proc.id), 11)}${statusColor}${pad(proc.status, 9)}${resetColor}${pad((proc.pid || '-').toString(), 9)}${pad(formatMemory(proc.memory), 8)}${pad(proc.restarts.toString(), 8)}`, `${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( console.log(
'└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘', '└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘',
); );
}, },
{ actionLabel: 'list processes' }, { actionLabel: 'list processes' },

View File

@@ -144,6 +144,13 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
await withStreamingLifecycle( await withStreamingLifecycle(
async () => { async () => {
// Optional: debug subscribers if requested via env (hidden)
if (process.env.TSPM_DEBUG === 'true') {
try {
const subInfo = await tspmIpcClient.request('logs:subscribers' as any, { id });
console.log(`[DEBUG] Subscribers for logs.${id}: ${subInfo.count} (${(subInfo.subscribers||[]).join(',')})`);
} catch {}
}
await tspmIpcClient.subscribe(id, (log: any) => { await tspmIpcClient.subscribe(id, (log: any) => {
// Reset sequence if runId changed (e.g., process restarted) // Reset sequence if runId changed (e.g., process restarted)
if (log.runId && log.runId !== lastRunId) { if (log.runId && log.runId !== lastRunId) {

View File

@@ -438,6 +438,13 @@ export class ProcessManager extends EventEmitter {
info.uptime = uptime; info.uptime = uptime;
} }
// Update memory and cpu from latest monitor readings
info.memory = monitor.getLastMemoryUsage();
const cpu = monitor.getLastCpuUsage();
if (Number.isFinite(cpu)) {
info.cpu = cpu;
}
// Update restart count // Update restart count
info.restarts = monitor.getRestartCount(); info.restarts = monitor.getRestartCount();

View File

@@ -24,6 +24,8 @@ export class ProcessMonitor extends EventEmitter {
private lastRetryAt: number | null = null; private lastRetryAt: number | null = null;
private readonly MAX_RETRIES = 10; private readonly MAX_RETRIES = 10;
private readonly RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour private readonly RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
private lastMemoryUsage: number = 0;
private lastCpuUsage: number = 0;
constructor(config: IMonitorConfig & { id?: ProcessId }) { constructor(config: IMonitorConfig & { id?: ProcessId }) {
super(); super();
@@ -260,12 +262,16 @@ export class ProcessMonitor extends EventEmitter {
memoryLimit: number, memoryLimit: number,
): Promise<void> { ): Promise<void> {
try { try {
const memoryUsage = await this.getProcessGroupMemory(pid); const { memory: memoryUsage, cpu: cpuUsage } = await this.getProcessGroupStats(pid);
this.logger.debug( this.logger.debug(
`Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`, `Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`,
); );
// Store latest readings
this.lastMemoryUsage = memoryUsage;
this.lastCpuUsage = cpuUsage;
// Only log memory usage in debug mode to avoid spamming // Only log memory usage in debug mode to avoid spamming
if (process.env.TSPM_DEBUG) { if (process.env.TSPM_DEBUG) {
this.log( this.log(
@@ -285,7 +291,7 @@ export class ProcessMonitor extends EventEmitter {
// Stop the process wrapper, which will trigger the exit handler and restart // Stop the process wrapper, which will trigger the exit handler and restart
if (this.processWrapper) { if (this.processWrapper) {
this.processWrapper.stop(); await this.processWrapper.stop();
} }
} }
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
@@ -303,7 +309,7 @@ export class ProcessMonitor extends EventEmitter {
/** /**
* Get the total memory usage (in bytes) for the process group (the main process and its children). * Get the total memory usage (in bytes) for the process group (the main process and its children).
*/ */
private getProcessGroupMemory(pid: number): Promise<number> { private getProcessGroupStats(pid: number): Promise<{ memory: number; cpu: number }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.logger.debug( this.logger.debug(
`Getting memory usage for process group with PID ${pid}`, `Getting memory usage for process group with PID ${pid}`,
@@ -333,7 +339,7 @@ export class ProcessMonitor extends EventEmitter {
plugins.pidusage( plugins.pidusage(
pids, pids,
(err: Error | null, stats: Record<string, { memory: number }>) => { (err: Error | null, stats: Record<string, { memory: number; cpu: number }>) => {
if (err) { if (err) {
const processError = new ProcessError( const processError = new ProcessError(
`Failed to get process usage stats: ${err.message}`, `Failed to get process usage stats: ${err.message}`,
@@ -345,14 +351,16 @@ export class ProcessMonitor extends EventEmitter {
} }
let totalMemory = 0; let totalMemory = 0;
let totalCpu = 0;
for (const key in stats) { for (const key in stats) {
totalMemory += stats[key].memory; totalMemory += stats[key].memory;
totalCpu += Number.isFinite(stats[key].cpu) ? stats[key].cpu : 0;
} }
this.logger.debug( this.logger.debug(
`Total memory for process group: ${this.humanReadableBytes(totalMemory)}`, `Total memory for process group: ${this.humanReadableBytes(totalMemory)}`,
); );
resolve(totalMemory); resolve({ memory: totalMemory, cpu: totalCpu });
}, },
); );
}, },
@@ -400,7 +408,7 @@ export class ProcessMonitor extends EventEmitter {
(plugins.pidusage as any)?.clear?.(pidToClear); (plugins.pidusage as any)?.clear?.(pidToClear);
} }
} catch {} } catch {}
this.processWrapper.stop(); await this.processWrapper.stop();
} }
} }
@@ -448,6 +456,20 @@ export class ProcessMonitor extends EventEmitter {
return this.processWrapper?.isRunning() || false; return this.processWrapper?.isRunning() || false;
} }
/**
* Get last measured memory usage for the process group (bytes)
*/
public getLastMemoryUsage(): number {
return this.lastMemoryUsage;
}
/**
* Get last measured CPU usage for the process group (sum of group, percent)
*/
public getLastCpuUsage(): number {
return this.lastCpuUsage;
}
/** /**
* Helper method for logging messages with the instance name. * Helper method for logging messages with the instance name.
*/ */

View File

@@ -180,7 +180,7 @@ export class ProcessWrapper extends EventEmitter {
/** /**
* Stop the wrapped process * Stop the wrapped process
*/ */
public stop(): void { public async stop(): Promise<void> {
if (!this.process) { if (!this.process) {
this.logger.debug('Stop called but no process is running'); this.logger.debug('Stop called but no process is running');
this.addSystemLog('No process running'); this.addSystemLog('No process running');
@@ -194,11 +194,32 @@ export class ProcessWrapper extends EventEmitter {
if (this.process.pid) { if (this.process.pid) {
try { try {
this.logger.debug(`Sending SIGTERM to process ${this.process.pid}`); 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'); process.kill(this.process.pid, 'SIGTERM');
}
} catch {
// Fallback to direct process kill if group kill fails
process.kill(this.process.pid, 'SIGTERM');
}
// Give it 5 seconds to shut down gracefully // Wait for exit or escalate
setTimeout((): void => { await new Promise<void>((resolve) => {
if (this.process && this.process.pid) { let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
resolve();
};
const onExit = () => cleanup();
this.process!.once('exit', onExit);
const killTimer = setTimeout(() => {
if (!this.process || !this.process.pid) return cleanup();
this.logger.warn( this.logger.warn(
`Process ${this.process.pid} did not exit gracefully, force killing...`, `Process ${this.process.pid} did not exit gracefully, force killing...`,
); );
@@ -206,17 +227,27 @@ export class ProcessWrapper extends EventEmitter {
'Process did not exit gracefully, force killing...', 'Process did not exit gracefully, force killing...',
); );
try { try {
if (process.platform !== 'win32') {
process.kill(-Math.abs(this.process.pid), 'SIGKILL');
} else {
process.kill(this.process.pid, 'SIGKILL'); process.kill(this.process.pid, 'SIGKILL');
} catch (error: Error | unknown) { }
// Process might have exited between checks } catch (error: any) {
this.logger.debug( this.logger.debug(
`Failed to send SIGKILL, process probably already exited: ${ `Failed to send SIGKILL, process probably already exited: ${error?.message || String(error)}`,
error instanceof Error ? error.message : String(error)
}`,
); );
} }
}
// Give a short grace period after SIGKILL
setTimeout(() => cleanup(), 500);
}, 5000); }, 5000);
// Safety cap in case neither exit nor timer fires (shouldn't happen)
setTimeout(() => {
clearTimeout(killTimer);
cleanup();
}, 10000);
});
} catch (error: Error | unknown) { } catch (error: Error | unknown) {
const processError = new ProcessError( const processError = new ProcessError(
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),

View File

@@ -293,7 +293,8 @@ export class TspmDaemon {
this.ipcServer.onMessage( this.ipcServer.onMessage(
'getLogs', 'getLogs',
async (request: RequestForMethod<'getLogs'>) => { async (request: RequestForMethod<'getLogs'>) => {
const logs = await this.tspmInstance.getLogs(toProcessId(request.id)); const id = toProcessId(request.id);
const logs = await this.tspmInstance.getLogs(id, request.lines);
return { logs }; return { logs };
}, },
); );
@@ -346,6 +347,26 @@ export class TspmDaemon {
}, },
); );
// Inspect subscribers for a process log topic
this.ipcServer.onMessage(
'logs:subscribers',
async (
request: RequestForMethod<'logs:subscribers'>,
clientId: string,
) => {
const id = toProcessId(request.id);
const topic = `logs.${id}`;
try {
const topicIndex = (this.ipcServer as any).topicIndex as Map<string, Set<string>> | undefined;
const subs = Array.from(topicIndex?.get(topic) || []);
// Also include the requesting clientId if it has a local handler without subscription
return { topic, subscribers: subs, count: subs.length } as any;
} catch (err: any) {
return { topic, subscribers: [], count: 0 } as any;
}
},
);
// Resolve target (id:n | name:foo | numeric string) to ProcessId // Resolve target (id:n | name:foo | numeric string) to ProcessId
this.ipcServer.onMessage( this.ipcServer.onMessage(
'resolveTarget', 'resolveTarget',

View File

@@ -151,6 +151,17 @@ export interface LogsSubscribeResponse {
ok: boolean; ok: boolean;
} }
// Inspect current subscribers for a process log topic
export interface LogsSubscribersRequest {
id: ProcessId;
}
export interface LogsSubscribersResponse {
topic: string;
subscribers: string[];
count: number;
}
// Start all command // Start all command
export interface StartAllRequest { export interface StartAllRequest {
// No parameters needed // No parameters needed
@@ -287,6 +298,7 @@ export type IpcMethodMap = {
describe: { request: DescribeRequest; response: DescribeResponse }; describe: { request: DescribeRequest; response: DescribeResponse };
getLogs: { request: GetLogsRequest; response: GetLogsResponse }; getLogs: { request: GetLogsRequest; response: GetLogsResponse };
'logs:subscribe': { request: LogsSubscribeRequest; response: LogsSubscribeResponse }; 'logs:subscribe': { request: LogsSubscribeRequest; response: LogsSubscribeResponse };
'logs:subscribers': { request: LogsSubscribersRequest; response: LogsSubscribersResponse };
startAll: { request: StartAllRequest; response: StartAllResponse }; startAll: { request: StartAllRequest; response: StartAllResponse };
stopAll: { request: StopAllRequest; response: StopAllResponse }; stopAll: { request: StopAllRequest; response: StopAllResponse };
restartAll: { request: RestartAllRequest; response: RestartAllResponse }; restartAll: { request: RestartAllRequest; response: RestartAllResponse };