Files
tspm/ts/daemon/processwrapper.ts

410 lines
13 KiB
TypeScript

import * as plugins from '../plugins.js';
import { EventEmitter } from 'events';
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
import type { IProcessLog } from '../shared/protocol/ipc.types.js';
export interface IProcessWrapperOptions {
command: string;
args?: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
name: string;
logBuffer?: number; // Number of log lines to keep in memory (default: 100)
}
export class ProcessWrapper extends EventEmitter {
private process: plugins.childProcess.ChildProcess | null = null;
private options: IProcessWrapperOptions;
private logs: IProcessLog[] = [];
private logBufferSize: number;
private startTime: Date | null = null;
private logger: Logger;
private nextSeq: number = 0;
private runId: string = '';
private stdoutRemainder: string = '';
private stderrRemainder: string = '';
// Store event handlers for cleanup
private exitHandler?: (code: number | null, signal: string | null) => void;
private errorHandler?: (error: Error) => void;
private stdoutDataHandler?: (data: Buffer) => void;
private stdoutEndHandler?: () => void;
private stderrDataHandler?: (data: Buffer) => void;
private stderrEndHandler?: () => void;
// 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;
this.logBufferSize = options.logBuffer || 100;
this.logger = new Logger(`ProcessWrapper:${options.name}`);
this.runId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Start the wrapped process
*/
public start(): void {
this.addSystemLog('Starting process...');
try {
this.logger.debug(`Starting process: ${this.options.command}`);
if (this.options.args && this.options.args.length > 0) {
this.process = plugins.childProcess.spawn(
this.options.command,
this.options.args,
{
cwd: this.options.cwd,
env: { ...process.env, ...(this.options.env || {}) },
stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr
},
);
} else {
// Use shell mode to allow a full command string
this.process = plugins.childProcess.spawn(this.options.command, {
cwd: this.options.cwd,
env: { ...process.env, ...(this.options.env || {}) },
stdio: ['ignore', 'pipe', 'pipe'], // We need to pipe stdout and stderr
shell: true,
});
}
this.startTime = new Date();
// Handle process exit
this.exitHandler = (code, signal) => {
const exitMessage = `Process exited with code ${code}, signal ${signal}`;
this.logger.info(exitMessage);
this.addSystemLog(exitMessage);
// Clear remainder buffers on exit
this.stdoutRemainder = '';
this.stderrRemainder = '';
// Mark process reference as gone so isRunning() reflects reality
this.process = null;
this.emit('exit', code, signal);
};
this.process.on('exit', this.exitHandler);
// Handle errors
this.errorHandler = (error) => {
const processError = new ProcessError(
error.message,
'ERR_PROCESS_EXECUTION',
{ command: this.options.command, pid: this.process?.pid },
);
this.logger.error(processError);
this.addSystemLog(`Process error: ${processError.toString()}`);
this.emit('error', processError);
};
this.process.on('error', this.errorHandler);
// Capture stdout
if (this.process.stdout) {
if (process.env.TSPM_DEBUG) {
console.error(
`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`,
);
}
this.stdoutDataHandler = (data) => {
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
const text = this.stdoutRemainder + data.toString();
const lines = text.split('\n');
// The last element might be a partial line
this.stdoutRemainder = lines.pop() || '';
// Process complete lines
for (const line of lines) {
if (process.env.TSPM_DEBUG) {
console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
}
this.logger.debug(`Captured stdout: ${line}`);
this.addLog('stdout', line);
}
};
this.process.stdout.on('data', this.stdoutDataHandler);
// Flush remainder on stream end
this.stdoutEndHandler = () => {
if (this.stdoutRemainder) {
this.logger.debug(`Flushing stdout remainder: ${this.stdoutRemainder}`);
this.addLog('stdout', this.stdoutRemainder);
this.stdoutRemainder = '';
}
};
this.process.stdout.on('end', this.stdoutEndHandler);
} else {
this.logger.warn('Process stdout is null');
}
// Capture stderr
if (this.process.stderr) {
this.stderrDataHandler = (data) => {
// Add data to remainder buffer and split by newlines
const text = this.stderrRemainder + data.toString();
const lines = text.split('\n');
// The last element might be a partial line
this.stderrRemainder = lines.pop() || '';
// Process complete lines
for (const line of lines) {
this.addLog('stderr', line);
}
};
this.process.stderr.on('data', this.stderrDataHandler);
// Flush remainder on stream end
this.stderrEndHandler = () => {
if (this.stderrRemainder) {
this.addLog('stderr', this.stderrRemainder);
this.stderrRemainder = '';
}
};
this.process.stderr.on('end', this.stderrEndHandler);
}
this.addSystemLog(`Process started with PID ${this.process.pid}`);
this.logger.info(`Process started with PID ${this.process.pid}`);
this.emit('start', this.process.pid);
} catch (error: Error | unknown) {
const processError =
error instanceof ProcessError
? error
: new ProcessError(
error instanceof Error ? error.message : String(error),
'ERR_PROCESS_START_FAILED',
{ command: this.options.command },
);
this.logger.error(processError);
this.addSystemLog(`Failed to start process: ${processError.toString()}`);
this.emit('error', processError);
throw processError;
}
}
/**
* Clean up event listeners from process and streams
*/
private cleanupListeners(): void {
if (this.process) {
if (this.exitHandler) {
this.process.removeListener('exit', this.exitHandler);
}
if (this.errorHandler) {
this.process.removeListener('error', this.errorHandler);
}
if (this.process.stdout) {
if (this.stdoutDataHandler) {
this.process.stdout.removeListener('data', this.stdoutDataHandler);
}
if (this.stdoutEndHandler) {
this.process.stdout.removeListener('end', this.stdoutEndHandler);
}
}
if (this.process.stderr) {
if (this.stderrDataHandler) {
this.process.stderr.removeListener('data', this.stderrDataHandler);
}
if (this.stderrEndHandler) {
this.process.stderr.removeListener('end', this.stderrEndHandler);
}
}
}
// Clear references
this.exitHandler = undefined;
this.errorHandler = undefined;
this.stdoutDataHandler = undefined;
this.stdoutEndHandler = undefined;
this.stderrDataHandler = undefined;
this.stderrEndHandler = undefined;
}
/**
* Stop the wrapped process
*/
public async stop(): Promise<void> {
if (!this.process) {
this.logger.debug('Stop called but no process is running');
this.addSystemLog('No process running');
return;
}
// Clean up event listeners before stopping
this.cleanupListeners();
this.logger.info('Stopping process...');
this.addSystemLog('Stopping process...');
// First try SIGTERM for graceful shutdown
if (this.process.pid) {
try {
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
await new Promise<void>((resolve) => {
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
resolve();
};
const onExit = () => cleanup();
this.process!.once('exit', onExit);
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 tree...`,
);
this.addSystemLog('Process did not exit gracefully, force killing...');
try {
await this.killProcessTree('SIGKILL');
} catch {}
// Give a short grace period after SIGKILL
setTimeout(() => cleanup(), 500);
}, 5000);
// Safety cap in case neither exit nor timer fires (shouldn't happen)
setTimeout(() => {
clearTimeout(killTimer);
cleanup();
}, 10000);
});
} catch (error: Error | unknown) {
const processError = new ProcessError(
error instanceof Error ? error.message : String(error),
'ERR_PROCESS_STOP_FAILED',
{ pid: this.process.pid },
);
this.logger.error(processError);
this.addSystemLog(`Error stopping process: ${processError.toString()}`);
}
}
}
/**
* Get the process ID if running
*/
public getPid(): number | null {
if (!this.isRunning()) return null;
return this.process?.pid || null;
}
/**
* Get the current logs
*/
public getLogs(limit: number = this.logBufferSize): IProcessLog[] {
// Return the most recent logs up to the limit
return this.logs.slice(-limit);
}
/**
* Get uptime in milliseconds
*/
public getUptime(): number {
if (!this.startTime) return 0;
return Date.now() - this.startTime.getTime();
}
/**
* Check if the process is currently running
*/
public isRunning(): boolean {
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');
}
/**
* Add a log entry from stdout or stderr
*/
private addLog(type: 'stdout' | 'stderr', message: string): void {
const log: IProcessLog = {
timestamp: new Date(),
type,
message,
seq: this.nextSeq++,
runId: this.runId,
};
this.logs.push(log);
// Trim logs if they exceed buffer size
if (this.logs.length > this.logBufferSize) {
this.logs = this.logs.slice(-this.logBufferSize);
}
// Emit log event for potential handlers
this.emit('log', log);
}
/**
* Add a system log entry (not from the process itself)
*/
private addSystemLog(message: string): void {
const log: IProcessLog = {
timestamp: new Date(),
type: 'system',
message,
seq: this.nextSeq++,
runId: this.runId,
};
this.logs.push(log);
// Trim logs if they exceed buffer size
if (this.logs.length > this.logBufferSize) {
this.logs = this.logs.slice(-this.logBufferSize);
}
// Emit log event for potential handlers
this.emit('log', log);
}
}