From 2ffaeff4b53a1eee7078a0b1c50d1741fae74e58 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Sat, 1 Mar 2025 12:15:22 +0000 Subject: [PATCH] feat(core): Introduce ProcessMonitor class and integrate native and external plugins --- changelog.md | 7 ++ package.json | 4 +- pnpm-lock.yaml | 4 + ts/00_commitinfo_data.ts | 2 +- ts/classes.processmonitor.ts | 178 +++++++++++++++++++++++++++++++++++ ts/classes.tspm.ts | 6 ++ ts/index.ts | 2 +- ts/plugins.ts | 13 +++ ts/tspm.plugins.ts | 2 - 9 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 ts/classes.processmonitor.ts create mode 100644 ts/classes.tspm.ts create mode 100644 ts/plugins.ts delete mode 100644 ts/tspm.plugins.ts diff --git a/changelog.md b/changelog.md index efc1e07..1a0e2ab 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2025-03-01 - 1.1.0 - feat(core) +Introduce ProcessMonitor class and integrate native and external plugins + +- Added a new ProcessMonitor class to manage and monitor child processes with memory constraints. +- Integrated native 'path' and external '@push.rocks/smartpath' packages in a unified plugins file. +- Adjusted index and related files for improved modular structure. + ## 2025-02-24 - 1.0.3 - fix(core) Corrected description in package.json and readme.md from 'task manager' to 'process manager'. diff --git a/package.json b/package.json index b505049..1c928a3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "@push.rocks/tapbundle": "^5.0.15", "@types/node": "^20.8.7" }, - "dependencies": {}, + "dependencies": { + "@push.rocks/smartpath": "^5.0.18" + }, "repository": { "type": "git", "url": "https://code.foss.global/git.zone/tspm.git" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b3892..e84b80a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@push.rocks/smartpath': + specifier: ^5.0.18 + version: 5.0.18 devDependencies: '@git.zone/tsbuild': specifier: ^2.1.25 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 6cda06d..bfd25c0 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tspm', - version: '1.0.3', + version: '1.1.0', description: 'a no fuzz process manager' } diff --git a/ts/classes.processmonitor.ts b/ts/classes.processmonitor.ts new file mode 100644 index 0000000..db99a06 --- /dev/null +++ b/ts/classes.processmonitor.ts @@ -0,0 +1,178 @@ +import { spawn, ChildProcess } from 'child_process'; +import psTree from 'ps-tree'; +import pidusage from 'pidusage'; + +interface IMonitorConfig { + name?: string; // Optional name to identify the instance + projectDir: string; // Directory where the command will run + command: string; // Full command to run (e.g., "npm run xyz") + args?: string[]; // Optional: arguments for the command + memoryLimitBytes: number; // Maximum allowed memory (in bytes) for the process group + monitorIntervalMs?: number; // Interval (in ms) at which memory is checked (default: 5000) +} + +class ProcessMonitor { + private child: ChildProcess | null = null; + private config: IMonitorConfig; + private intervalId: NodeJS.Timeout | null = null; + private stopped: boolean = true; // Initially stopped until start() is called + + constructor(config: IMonitorConfig) { + this.config = config; + } + + public start(): void { + // Reset the stopped flag so that new processes can spawn. + this.stopped = false; + this.log(`Starting process monitor.`); + this.spawnChild(); + + // Set the monitoring interval. + const interval = this.config.monitorIntervalMs || 5000; + this.intervalId = setInterval(() => { + if (this.child && this.child.pid) { + this.monitorProcessGroup(this.child.pid, this.config.memoryLimitBytes); + } + }, interval); + } + + private spawnChild(): void { + // Don't spawn if the monitor has been stopped. + if (this.stopped) return; + + if (this.config.args && this.config.args.length > 0) { + this.log( + `Spawning command "${this.config.command}" with args [${this.config.args.join( + ', ' + )}] in directory: ${this.config.projectDir}` + ); + this.child = spawn(this.config.command, this.config.args, { + cwd: this.config.projectDir, + detached: true, + stdio: 'inherit', + }); + } else { + this.log( + `Spawning command "${this.config.command}" in directory: ${this.config.projectDir}` + ); + // Use shell mode to allow a full command string. + this.child = spawn(this.config.command, { + cwd: this.config.projectDir, + detached: true, + stdio: 'inherit', + shell: true, + }); + } + + this.log(`Spawned process with PID ${this.child.pid}`); + + // When the child process exits, restart it if the monitor isn't stopped. + this.child.on('exit', (code, signal) => { + this.log(`Child process exited with code ${code}, signal ${signal}.`); + if (!this.stopped) { + this.log('Restarting process...'); + this.spawnChild(); + } + }); + } + + /** + * Monitor the process group’s memory usage. If the total memory exceeds the limit, + * kill the process group so that the 'exit' handler can restart it. + */ + private async monitorProcessGroup(pid: number, memoryLimit: number): Promise { + try { + const memoryUsage = await this.getProcessGroupMemory(pid); + this.log( + `Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes( + memoryUsage + )} (${memoryUsage} bytes)` + ); + if (memoryUsage > memoryLimit) { + this.log( + `Memory usage ${this.humanReadableBytes( + memoryUsage + )} exceeds limit of ${this.humanReadableBytes(memoryLimit)}. Restarting process.` + ); + // Kill the entire process group by sending a signal to -PID. + process.kill(-pid, 'SIGKILL'); + } + } catch (error) { + this.log('Error monitoring process group: ' + error); + } + } + + /** + * Get the total memory usage (in bytes) for the process group (the main process and its children). + */ + private getProcessGroupMemory(pid: number): Promise { + return new Promise((resolve, reject) => { + psTree(pid, (err, children) => { + if (err) return reject(err); + // Include the main process and its children. + const pids: number[] = [pid, ...children.map(child => Number(child.PID))]; + pidusage(pids, (err, stats) => { + if (err) return reject(err); + let totalMemory = 0; + for (const key in stats) { + totalMemory += stats[key].memory; + } + resolve(totalMemory); + }); + }); + }); + } + + /** + * Convert a number of bytes into a human-readable string (e.g. "1.23 MB"). + */ + private humanReadableBytes(bytes: number, decimals: number = 2): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + + /** + * Stop the monitor and prevent any further respawns. + */ + public stop(): void { + this.log('Stopping process monitor.'); + this.stopped = true; + if (this.intervalId) { + clearInterval(this.intervalId); + } + if (this.child && this.child.pid) { + process.kill(-this.child.pid, 'SIGKILL'); + } + } + + /** + * Helper method for logging messages with the instance name. + */ + private log(message: string): void { + const prefix = this.config.name ? `[${this.config.name}] ` : ''; + console.log(prefix + message); + } +} + +// Example usage: +const config: IMonitorConfig = { + name: 'Project XYZ Monitor', // Identifier for the instance + projectDir: '/path/to/your/project', // Set the project directory here + command: 'npm run xyz', // Full command string (no need for args) + memoryLimitBytes: 500 * 1024 * 1024, // 500 MB memory limit + monitorIntervalMs: 5000, // Check memory usage every 5 seconds +}; + +const monitor = new ProcessMonitor(config); +monitor.start(); + +// Ensure that on process exit (e.g. Ctrl+C) we clean up the child process and prevent respawns. +process.on('SIGINT', () => { + monitor.log('Received SIGINT, stopping monitor...'); + monitor.stop(); + process.exit(); +}); \ No newline at end of file diff --git a/ts/classes.tspm.ts b/ts/classes.tspm.ts new file mode 100644 index 0000000..3a591d5 --- /dev/null +++ b/ts/classes.tspm.ts @@ -0,0 +1,6 @@ +import * as plugins from './plugins.js'; +import * as paths from './paths.js'; + +export class Tspm { + +} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts index 9011c92..8f1f224 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,3 +1,3 @@ -import * as plugins from './tspm.plugins.js'; +import * as plugins from './plugins.js'; export let demoExport = 'Hi there! :) This is an exported string'; diff --git a/ts/plugins.ts b/ts/plugins.ts new file mode 100644 index 0000000..5585cdd --- /dev/null +++ b/ts/plugins.ts @@ -0,0 +1,13 @@ +// native scope +import * as path from 'node:path'; + +export { + path, +} + +// @push.rocks scope +import * as smartpath from '@push.rocks/smartpath'; + +export { + smartpath, +} \ No newline at end of file diff --git a/ts/tspm.plugins.ts b/ts/tspm.plugins.ts deleted file mode 100644 index 29aa9da..0000000 --- a/ts/tspm.plugins.ts +++ /dev/null @@ -1,2 +0,0 @@ -const removeme = {}; -export { removeme };