Compare commits

...

4 Commits

Author SHA1 Message Date
92a6ecac71 5.4.0
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 3m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 22:01:19 +00:00
5e26b0ab5f feat(daemon): Add CLI systemd service refresh on version mismatch and fix daemon memory leak; update dependencies 2025-08-30 22:01:19 +00:00
e09cf38f30 5.3.2
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 3m58s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 21:50:43 +00:00
c694672438 fix(daemon): Improve daemon log delivery and process monitor memory accounting; gate debug output and update tests to numeric ProcessId 2025-08-30 21:50:43 +00:00
12 changed files with 194 additions and 43 deletions

View File

@@ -1,5 +1,25 @@
# Changelog # Changelog
## 2025-08-30 - 5.4.0 - feat(daemon)
Add CLI systemd service refresh on version mismatch and fix daemon memory leak; update dependencies
- CLI: when client and daemon versions differ, prompt to refresh the systemd service and optionally disable/enable the service automatically
- Daemon: clear pidusage state for PIDs on process exit/stop to prevent memory leaks in long-running monitors
- Client: expose smartdaemon in client plugin exports and fix import path for tspm.servicemanager
- Package: tighten dependency ranges (set specific versions) and add @types for pidusage and ps-tree
- Misc: ensure IPC disconnects and PID/socket handling improvements were integrated alongside the above changes
## 2025-08-30 - 5.3.2 - fix(daemon)
Improve daemon log delivery and process monitor memory accounting; gate debug output and update tests to numeric ProcessId
- Deliver process logs only to subscribed clients instead of broadcasting to all connections (reduce unnecessary IPC traffic and noise)
- Implement incremental log memory accounting in ProcessMonitor using an estimateLogSize helper and WeakMap to avoid repeated JSON.stringify and reduce CPU/memory overhead
- Seed the incremental size map when loading persisted logs so memory accounting is accurate after restart
- Trim logs incrementally by subtracting estimated sizes of removed entries (avoids O(n) recalculation)
- Gate verbose console/debug output behind TSPM_DEBUG to prevent spamming in normal runs (applies to ProcessWrapper and ProcessMonitor)
- Improve process wrapper stdout/stderr debug logging to be conditional on debug mode
- Update tests to use numeric ProcessId via toProcessId(...) for consistency with typed IDs
## 2025-08-30 - 5.3.1 - fix(client(tspmIpcClient)) ## 2025-08-30 - 5.3.1 - fix(client(tspmIpcClient))
Use bare topic names for IPC client subscribe/unsubscribe to fix log subscription issues Use bare topic names for IPC client subscribe/unsubscribe to fix log subscription issues

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tspm", "name": "@git.zone/tspm",
"version": "5.3.1", "version": "5.4.0",
"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",
@@ -40,6 +40,8 @@
"@push.rocks/smartinteract": "^2.0.16", "@push.rocks/smartinteract": "^2.0.16",
"@push.rocks/smartipc": "^2.2.2", "@push.rocks/smartipc": "^2.2.2",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@types/pidusage": "^2.0.5",
"@types/ps-tree": "^1.1.6",
"pidusage": "^4.0.1", "pidusage": "^4.0.1",
"ps-tree": "^1.2.0", "ps-tree": "^1.2.0",
"tsx": "^4.20.5" "tsx": "^4.20.5"

16
pnpm-lock.yaml generated
View File

@@ -32,6 +32,12 @@ importers:
'@push.rocks/smartpath': '@push.rocks/smartpath':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
'@types/pidusage':
specifier: ^2.0.5
version: 2.0.5
'@types/ps-tree':
specifier: ^1.1.6
version: 1.1.6
pidusage: pidusage:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -1647,9 +1653,15 @@ packages:
'@types/parse5@6.0.3': '@types/parse5@6.0.3':
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
'@types/pidusage@2.0.5':
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
'@types/ping@0.4.4': '@types/ping@0.4.4':
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==} resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
'@types/ps-tree@1.1.6':
resolution: {integrity: sha512-PtrlVaOaI44/3pl3cvnlK+GxOM3re2526TJvPvh7W+keHIXdV4TE0ylpPBAcvFQCbGitaTXwL9u+RF7qtVeazQ==}
'@types/qs@6.14.0': '@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -7592,8 +7604,12 @@ snapshots:
'@types/parse5@6.0.3': {} '@types/parse5@6.0.3': {}
'@types/pidusage@2.0.5': {}
'@types/ping@0.4.4': {} '@types/ping@0.4.4': {}
'@types/ps-tree@1.1.6': {}
'@types/qs@6.14.0': {} '@types/qs@6.14.0': {}
'@types/randomatic@3.1.5': {} '@types/randomatic@3.1.5': {}

View File

@@ -5,6 +5,7 @@ import * as fs from 'fs/promises';
import * as os from 'os'; import * as os from 'os';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { tspmIpcClient, TspmIpcClient } from '../ts/client/tspm.ipcclient.js'; import { tspmIpcClient, TspmIpcClient } from '../ts/client/tspm.ipcclient.js';
import { toProcessId } from '../ts/shared/protocol/id.js';
// Helper to ensure daemon is stopped before tests // Helper to ensure daemon is stopped before tests
async function ensureDaemonStopped() { async function ensureDaemonStopped() {
@@ -160,7 +161,7 @@ tap.test('Process management through daemon', async (tools) => {
// Test 2: Start a test process // Test 2: Start a test process
const testConfig: tspm.IProcessConfig = { const testConfig: tspm.IProcessConfig = {
id: 'test-echo', id: toProcessId(1001),
name: 'Test Echo Process', name: 'Test Echo Process',
command: 'echo "Test process"', command: 'echo "Test process"',
projectDir: process.cwd(), projectDir: process.cwd(),
@@ -172,7 +173,7 @@ tap.test('Process management through daemon', async (tools) => {
config: testConfig, config: testConfig,
}); });
console.log('Start response:', startResponse); console.log('Start response:', startResponse);
expect(startResponse.processId).toEqual('test-echo'); expect(startResponse.processId).toEqual(1001);
expect(startResponse.status).toBeDefined(); expect(startResponse.status).toBeDefined();
// Test 3: List processes (should have one process) // Test 3: List processes (should have one process)
@@ -180,27 +181,27 @@ tap.test('Process management through daemon', async (tools) => {
console.log('List after start:', listResponse); console.log('List after start:', listResponse);
expect(listResponse.processes.length).toBeGreaterThanOrEqual(1); expect(listResponse.processes.length).toBeGreaterThanOrEqual(1);
const procInfo = listResponse.processes.find((p) => p.id === 'test-echo'); const procInfo = listResponse.processes.find((p) => p.id === toProcessId(1001));
expect(procInfo).toBeDefined(); expect(procInfo).toBeDefined();
expect(procInfo?.id).toEqual('test-echo'); expect(procInfo?.id).toEqual(1001);
// Test 4: Describe the process // Test 4: Describe the process
const describeResponse = await tspmIpcClient.request('describe', { const describeResponse = await tspmIpcClient.request('describe', {
id: 'test-echo', id: toProcessId(1001),
}); });
console.log('Describe:', describeResponse); console.log('Describe:', describeResponse);
expect(describeResponse.processInfo).toBeDefined(); expect(describeResponse.processInfo).toBeDefined();
expect(describeResponse.config).toBeDefined(); expect(describeResponse.config).toBeDefined();
expect(describeResponse.config.id).toEqual('test-echo'); expect(describeResponse.config.id).toEqual(1001);
// Test 5: Stop the process // Test 5: Stop the process
const stopResponse = await tspmIpcClient.request('stop', { id: 'test-echo' }); const stopResponse = await tspmIpcClient.request('stop', { id: toProcessId(1001) });
console.log('Stop response:', stopResponse); console.log('Stop response:', stopResponse);
expect(stopResponse.success).toEqual(true); expect(stopResponse.success).toEqual(true);
// Test 6: Delete the process // Test 6: Delete the process
const deleteResponse = await tspmIpcClient.request('delete', { const deleteResponse = await tspmIpcClient.request('delete', {
id: 'test-echo', id: toProcessId(1001),
}); });
console.log('Delete response:', deleteResponse); console.log('Delete response:', deleteResponse);
expect(deleteResponse.success).toEqual(true); expect(deleteResponse.success).toEqual(true);
@@ -208,9 +209,7 @@ tap.test('Process management through daemon', async (tools) => {
// Test 7: Verify process is gone // Test 7: Verify process is gone
listResponse = await tspmIpcClient.request('list', {}); listResponse = await tspmIpcClient.request('list', {});
console.log('List after delete:', listResponse); console.log('List after delete:', listResponse);
const deletedProcess = listResponse.processes.find( const deletedProcess = listResponse.processes.find((p) => p.id === toProcessId(1001));
(p) => p.id === 'test-echo',
);
expect(deletedProcess).toBeUndefined(); expect(deletedProcess).toBeUndefined();
// Cleanup: stop daemon // Cleanup: stop daemon
@@ -241,7 +240,7 @@ tap.test('Batch operations through daemon', async (tools) => {
// Add multiple test processes // Add multiple test processes
const testConfigs: tspm.IProcessConfig[] = [ const testConfigs: tspm.IProcessConfig[] = [
{ {
id: 'batch-test-1', id: toProcessId(1101),
name: 'Batch Test 1', name: 'Batch Test 1',
command: 'echo "Process 1"', command: 'echo "Process 1"',
projectDir: process.cwd(), projectDir: process.cwd(),
@@ -249,7 +248,7 @@ tap.test('Batch operations through daemon', async (tools) => {
autorestart: false, autorestart: false,
}, },
{ {
id: 'batch-test-2', id: toProcessId(1102),
name: 'Batch Test 2', name: 'Batch Test 2',
command: 'echo "Process 2"', command: 'echo "Process 2"',
projectDir: process.cwd(), projectDir: process.cwd(),
@@ -308,7 +307,7 @@ tap.test('Daemon error handling', async (tools) => {
// Test 1: Try to stop non-existent process // Test 1: Try to stop non-existent process
try { try {
await tspmIpcClient.request('stop', { id: 'non-existent-process' }); await tspmIpcClient.request('stop', { id: toProcessId(99999) });
expect(false).toEqual(true); // Should not reach here expect(false).toEqual(true); // Should not reach here
} catch (error) { } catch (error) {
expect(error.message).toInclude('Failed to stop process'); expect(error.message).toInclude('Failed to stop process');
@@ -316,7 +315,7 @@ tap.test('Daemon error handling', async (tools) => {
// Test 2: Try to describe non-existent process // Test 2: Try to describe non-existent process
try { try {
await tspmIpcClient.request('describe', { id: 'non-existent-process' }); await tspmIpcClient.request('describe', { id: toProcessId(99999) });
expect(false).toEqual(true); // Should not reach here expect(false).toEqual(true); // Should not reach here
} catch (error) { } catch (error) {
expect(error.message).toInclude('not found'); expect(error.message).toInclude('not found');
@@ -324,7 +323,7 @@ tap.test('Daemon error handling', async (tools) => {
// Test 3: Try to restart non-existent process // Test 3: Try to restart non-existent process
try { try {
await tspmIpcClient.request('restart', { id: 'non-existent-process' }); await tspmIpcClient.request('restart', { id: toProcessId(99999) });
expect(false).toEqual(true); // Should not reach here expect(false).toEqual(true); // Should not reach here
} catch (error) { } catch (error) {
expect(error.message).toInclude('Failed to restart process'); expect(error.message).toInclude('Failed to restart process');

View File

@@ -1,5 +1,6 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as tspm from '../ts/index.js'; import * as tspm from '../ts/index.js';
import { toProcessId } from '../ts/shared/protocol/id.js';
import { join } from 'path'; import { join } from 'path';
// Basic module import test // Basic module import test
@@ -51,7 +52,7 @@ async function exampleUsingIpcClient() {
// Start a process using the request method // Start a process using the request method
await client.request('start', { await client.request('start', {
config: { config: {
id: 'web-server', id: toProcessId(2001),
name: 'Web Server', name: 'Web Server',
projectDir: '/path/to/web/project', projectDir: '/path/to/web/project',
command: 'npm run serve', command: 'npm run serve',
@@ -65,7 +66,7 @@ async function exampleUsingIpcClient() {
// Start another process // Start another process
await client.request('start', { await client.request('start', {
config: { config: {
id: 'api-server', id: toProcessId(2002),
name: 'API Server', name: 'API Server',
projectDir: '/path/to/api/project', projectDir: '/path/to/api/project',
command: 'npm run api', command: 'npm run api',
@@ -80,13 +81,13 @@ async function exampleUsingIpcClient() {
// Get logs from a process // Get logs from a process
const logs = await client.request('getLogs', { const logs = await client.request('getLogs', {
id: 'web-server', id: toProcessId(2001),
lines: 20, lines: 20,
}); });
console.log('Web server logs:', logs.logs); console.log('Web server logs:', logs.logs);
// Stop a process // Stop a process
await client.request('stop', { id: 'api-server' }); await client.request('stop', { id: toProcessId(2002) });
// Handle graceful shutdown // Handle graceful shutdown
process.on('SIGINT', async () => { process.on('SIGINT', async () => {

View File

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

View File

@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
import { tspmIpcClient } from '../client/tspm.ipcclient.js'; import { tspmIpcClient } from '../client/tspm.ipcclient.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
import { Logger, LogLevel } from '../shared/common/utils.errorhandler.js'; import { Logger, LogLevel } from '../shared/common/utils.errorhandler.js';
import { TspmServiceManager } from '../client/tspm.servicemanager.js';
// Import command registration functions // Import command registration functions
import { registerDefaultCommand } from './commands/default.js'; import { registerDefaultCommand } from './commands/default.js';
@@ -51,6 +52,38 @@ export const run = async (): Promise<void> => {
console.log( console.log(
`Daemon: running v${status.version || 'unknown'} (pid ${status.pid})`, `Daemon: running v${status.version || 'unknown'} (pid ${status.pid})`,
); );
// If versions mismatch, offer to refresh the systemd service
if (status.version && status.version !== cliVersion) {
console.log('\nVersion mismatch detected:');
console.log(` CLI: v${cliVersion}`);
console.log(` Daemon: v${status.version}`);
console.log(
'\nThis can happen after upgrading tspm. The systemd service may still point to an older version.\n' +
'You can refresh the service (equivalent to "tspm disable" then "tspm enable").',
);
// Ask the user for confirmation
const confirm = await plugins.smartinteract.SmartInteract.getCliConfirmation(
'Refresh the systemd service now?',
true,
);
if (confirm) {
try {
const sm = new TspmServiceManager();
console.log('Refreshing TSPM system service...');
await sm.disableService();
await sm.enableService();
console.log('✓ Service refreshed. Daemon restarted via systemd.');
} catch (err: any) {
console.error(
'Failed to refresh service automatically. You can try manually:\n tspm disable && tspm enable',
);
console.error(err?.message || String(err));
}
} else {
console.log('Skipped service refresh.');
}
}
} else { } else {
console.log('Daemon: not running'); console.log('Daemon: not running');
} }

View File

@@ -1,6 +1,7 @@
// Minimal plugin set for lightweight client startup // Minimal plugin set for lightweight client startup
import * as path from 'node:path'; import * as path from 'node:path';
import * as smartdaemon from '@push.rocks/smartdaemon';
import * as smartipc from '@push.rocks/smartipc'; import * as smartipc from '@push.rocks/smartipc';
export { path, smartipc }; export { path, smartdaemon, smartipc };

View File

@@ -1,4 +1,4 @@
import * as plugins from '../plugins.js'; import * as plugins from './plugins.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
/** /**

View File

@@ -18,6 +18,8 @@ export class ProcessMonitor extends EventEmitter {
private processId?: ProcessId; private processId?: ProcessId;
private currentLogMemorySize: number = 0; private currentLogMemorySize: number = 0;
private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB
// Track approximate size per log to avoid O(n) JSON stringify on every update
private logSizeMap: WeakMap<IProcessLog, number> = new WeakMap();
private restartTimer: NodeJS.Timeout | null = null; private restartTimer: NodeJS.Timeout | null = null;
private lastRetryAt: number | null = null; private lastRetryAt: number | null = null;
private readonly MAX_RETRIES = 10; private readonly MAX_RETRIES = 10;
@@ -39,7 +41,13 @@ export class ProcessMonitor extends EventEmitter {
const persistedLogs = await this.logPersistence.loadLogs(this.processId); const persistedLogs = await this.logPersistence.loadLogs(this.processId);
if (persistedLogs.length > 0) { if (persistedLogs.length > 0) {
this.logs = persistedLogs; this.logs = persistedLogs;
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs); // Recalculate size once from scratch and seed the size map
this.currentLogMemorySize = 0;
for (const log of this.logs) {
const size = this.estimateLogSize(log);
this.logSizeMap.set(log, size);
this.currentLogMemorySize += size;
}
this.logger.info(`Loaded ${persistedLogs.length} persisted logs from disk`); this.logger.info(`Loaded ${persistedLogs.length} persisted logs from disk`);
// Delete the persisted file after loading // Delete the persisted file after loading
@@ -87,18 +95,27 @@ export class ProcessMonitor extends EventEmitter {
this.processWrapper.on('log', (log: IProcessLog): void => { this.processWrapper.on('log', (log: IProcessLog): void => {
// Store the log in our buffer // Store the log in our buffer
this.logs.push(log); this.logs.push(log);
console.error(`[ProcessMonitor:${this.config.name}] Received log (type=${log.type}): ${log.message}`); if (process.env.TSPM_DEBUG) {
console.error(`[ProcessMonitor:${this.config.name}] Logs array now has ${this.logs.length} items`); console.error(
`[ProcessMonitor:${this.config.name}] Received log (type=${log.type}): ${log.message}`,
);
console.error(
`[ProcessMonitor:${this.config.name}] Logs array now has ${this.logs.length} items`,
);
}
this.logger.debug(`ProcessMonitor received log: ${log.message}`); this.logger.debug(`ProcessMonitor received log: ${log.message}`);
// Update memory size tracking // Update memory size tracking incrementally
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs); const approxSize = this.estimateLogSize(log);
this.logSizeMap.set(log, approxSize);
this.currentLogMemorySize += approxSize;
// Trim logs if they exceed memory limit (10MB) // Trim logs if they exceed memory limit (10MB)
while (this.currentLogMemorySize > this.MAX_LOG_MEMORY_SIZE && this.logs.length > 1) { while (this.currentLogMemorySize > this.MAX_LOG_MEMORY_SIZE && this.logs.length > 1) {
// Remove oldest logs until we're under the memory limit // Remove oldest logs until we're under the memory limit
this.logs.shift(); const removed = this.logs.shift()!;
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs); const removedSize = this.logSizeMap.get(removed) ?? this.estimateLogSize(removed);
this.currentLogMemorySize -= removedSize;
} }
// Re-emit the log event for upstream handlers // Re-emit the log event for upstream handlers
@@ -122,6 +139,14 @@ export class ProcessMonitor extends EventEmitter {
this.logger.info(exitMsg); this.logger.info(exitMsg);
this.log(exitMsg); this.log(exitMsg);
// Clear pidusage internal state for this PID to prevent memory leaks
try {
const pidToClear = this.processWrapper?.getPid();
if (pidToClear) {
(plugins.pidusage as any)?.clear?.(pidToClear);
}
} catch {}
// Flush logs to disk on exit // Flush logs to disk on exit
if (this.processId && this.logs.length > 0) { if (this.processId && this.logs.length > 0) {
try { try {
@@ -241,12 +266,14 @@ export class ProcessMonitor extends EventEmitter {
`Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`, `Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`,
); );
// Only log to the process log at longer intervals to avoid spamming // Only log memory usage in debug mode to avoid spamming
this.log( if (process.env.TSPM_DEBUG) {
`Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes( this.log(
memoryUsage, `Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes(
)} (${memoryUsage} bytes)`, memoryUsage,
); )} (${memoryUsage} bytes)`,
);
}
if (memoryUsage > memoryLimit) { if (memoryUsage > memoryLimit) {
const memoryLimitMsg = `Memory usage ${this.humanReadableBytes( const memoryLimitMsg = `Memory usage ${this.humanReadableBytes(
@@ -366,6 +393,13 @@ export class ProcessMonitor extends EventEmitter {
clearInterval(this.intervalId); clearInterval(this.intervalId);
} }
if (this.processWrapper) { if (this.processWrapper) {
// Clear pidusage state for current PID before stopping to avoid leaks
try {
const pidToClear = this.processWrapper.getPid();
if (pidToClear) {
(plugins.pidusage as any)?.clear?.(pidToClear);
}
} catch {}
this.processWrapper.stop(); this.processWrapper.stop();
} }
} }
@@ -374,7 +408,11 @@ export class ProcessMonitor extends EventEmitter {
* Get the current logs from the process * Get the current logs from the process
*/ */
public getLogs(limit?: number): IProcessLog[] { public getLogs(limit?: number): IProcessLog[] {
console.error(`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`); if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`,
);
}
this.logger.debug(`Getting logs, total stored: ${this.logs.length}`); this.logger.debug(`Getting logs, total stored: ${this.logs.length}`);
if (limit && limit > 0) { if (limit && limit > 0) {
return this.logs.slice(-limit); return this.logs.slice(-limit);
@@ -417,4 +455,17 @@ export class ProcessMonitor extends EventEmitter {
const prefix = this.config.name ? `[${this.config.name}] ` : ''; const prefix = this.config.name ? `[${this.config.name}] ` : '';
console.log(prefix + message); console.log(prefix + message);
} }
/**
* Estimate approximate memory size in bytes for a log entry.
* Keeps CPU low by avoiding JSON.stringify on the full array.
*/
private estimateLogSize(log: IProcessLog): number {
const messageBytes = Buffer.byteLength(log.message || '', 'utf8');
const typeBytes = Buffer.byteLength(log.type || '', 'utf8');
const runIdBytes = Buffer.byteLength((log as any).runId || '', 'utf8');
// Rough overhead for object structure, keys, timestamp/seq values
const overhead = 64;
return messageBytes + typeBytes + runIdBytes + overhead;
}
} }

View File

@@ -90,9 +90,19 @@ export class ProcessWrapper extends EventEmitter {
// Capture stdout // Capture stdout
if (this.process.stdout) { if (this.process.stdout) {
console.error(`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`); if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`,
);
}
this.process.stdout.on('data', (data) => { this.process.stdout.on('data', (data) => {
console.error(`[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data.toString().substring(0, 100)}`); if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data
.toString()
.substring(0, 100)}`,
);
}
// Add data to remainder buffer and split by newlines // Add data to remainder buffer and split by newlines
const text = this.stdoutRemainder + data.toString(); const text = this.stdoutRemainder + data.toString();
const lines = text.split('\n'); const lines = text.split('\n');
@@ -102,7 +112,9 @@ export class ProcessWrapper extends EventEmitter {
// Process complete lines // Process complete lines
for (const line of lines) { for (const line of lines) {
console.error(`[ProcessWrapper] Processing stdout line: ${line}`); if (process.env.TSPM_DEBUG) {
console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
}
this.logger.debug(`Captured stdout: ${line}`); this.logger.debug(`Captured stdout: ${line}`);
this.addLog('stdout', line); this.addLog('stdout', line);
} }

View File

@@ -97,9 +97,25 @@ export class TspmDaemon {
this.tspmInstance.on('process:log', ({ processId, log }) => { this.tspmInstance.on('process:log', ({ processId, log }) => {
// Publish to topic for this process // Publish to topic for this process
const topic = `logs.${processId}`; const topic = `logs.${processId}`;
// Broadcast to all connected clients subscribed to this topic // Deliver only to subscribed clients
if (this.ipcServer) { if (this.ipcServer) {
this.ipcServer.broadcast(`topic:${topic}`, log); try {
const topicIndex = (this.ipcServer as any).topicIndex as Map<string, Set<string>> | undefined;
const subscribers = topicIndex?.get(topic);
if (subscribers && subscribers.size > 0) {
// Send directly to subscribers for this topic
for (const clientId of subscribers) {
this.ipcServer
.sendToClient(clientId, `topic:${topic}`, log)
.catch((err: any) => {
// Surface but don't fail the loop
console.error('[IPC] sendToClient error:', err?.message || err);
});
}
}
} catch (err: any) {
console.error('[IPC] Topic delivery error:', err?.message || err);
}
} }
}); });