UNPKG

@comake/skl-js-engine

Version:

Standard Knowledge Language Javascript Engine

282 lines (242 loc) 8.13 kB
import type { ChildProcess } from 'child_process'; import { EventEmitter } from 'node:events'; import { Logger } from '../../../logger'; import { EXECUTION_CONSTANTS } from '../../constants'; import { spawnDenoProcess } from '../../denoUtils'; import { ProcessSpawnError } from '../../errors'; import { buildDenoPermissions, validatePermissionConfig } from '../../PermissionBuilder'; import type { ExecutionOptions, PermissionConfig } from '../../types'; import { pollUntilTrue } from '../utils/PollingUtils'; /** * Events emitted by the ProcessManager */ export interface ProcessManagerEvents { stdout: (data: Buffer) => void; stderr: (data: Buffer) => void; exit: (code: number | null, signal: NodeJS.Signals | null) => void; error: (error: Error) => void; ready: () => void; } /** * Configuration for process spawning */ export interface ProcessConfig { /** Script path to execute */ scriptPath: string; /** Execution options */ executionOptions?: ExecutionOptions; /** Timeout for process startup */ startupTimeout?: number; } /** * Manages the lifecycle of child processes for JavaScript execution */ export class ProcessManager extends EventEmitter { private childProcess?: ChildProcess; public readonly logger = Logger.getInstance(); private name?: string; public constructor() { super(); } /** * Set a name for this process manager (used in logging) */ public setName(name: string): void { this.name = name; this.logger.setMetadata({ name, component: 'ProcessManager' }); } /** * Get the current child process */ public getProcess(): ChildProcess | undefined { return this.childProcess; } /** * Check if the process is running */ public isRunning(): boolean { return this.childProcess !== undefined && !this.childProcess.killed; } /** * Spawn a new child process */ public async spawn(config: ProcessConfig): Promise<void> { if (this.childProcess) { throw new Error('Process already spawned. Call terminate() first.'); } try { const normalizedOptions = this.normalizeExecutionOptions(config.executionOptions); const permissionConfig = this.extractPermissionConfig(normalizedOptions); // Validate permissions before proceeding validatePermissionConfig(permissionConfig); const permissions = buildDenoPermissions(permissionConfig); // Build command arguments const commandArgs = [ ...EXECUTION_CONSTANTS.denoFlags, ...permissions, config.scriptPath ]; this.logger.debug('Spawning process with args:', commandArgs); // Spawn the Deno process this.childProcess = spawnDenoProcess(commandArgs); this.setupProcessHandlers(); // Wait for process to be ready await this.waitForReady(config.startupTimeout ?? 60 * 60 * 1000); this.emit('ready'); } catch (error: unknown) { this.childProcess = undefined; throw error; } } /** * Terminate the child process */ public async terminate(timeout = 3000): Promise<void> { if (!this.childProcess || this.childProcess.killed) { return; } this.logger.log('Terminating process'); // First try graceful termination this.childProcess.kill(EXECUTION_CONSTANTS.processSignals.term); // Wait for process to exit gracefully await new Promise<void>(resolve => { const timeoutId = setTimeout(() => { if (this.childProcess && !this.childProcess.killed) { this.logger.log('Force killing process after timeout'); this.childProcess.kill('SIGKILL'); } resolve(); }, timeout); this.childProcess!.on('exit', () => { clearTimeout(timeoutId); resolve(); }); }); this.childProcess = undefined; } /** * Send data to the process stdin */ public write(data: string): boolean { if (!this.childProcess?.stdin) { throw new Error('Process stdin not available'); } return this.childProcess.stdin.write(data); } /** * Get process stdout stream */ public getStdout(): NodeJS.ReadableStream | undefined { return this.childProcess?.stdout ?? undefined; } /** * Get process stdin stream */ public getStdin(): NodeJS.WritableStream | undefined { return this.childProcess?.stdin ?? undefined; } /** * Register event listeners */ public on<TKey extends keyof ProcessManagerEvents>(event: TKey, handler: ProcessManagerEvents[TKey]): this { return super.on(event, handler); } /** * Remove event listeners */ public off<TKey extends keyof ProcessManagerEvents>(event: TKey, handler: ProcessManagerEvents[TKey]): this { return super.off(event, handler); } /** * Set up process event handlers */ private setupProcessHandlers(): void { if (!this.childProcess) { return; } // Handle stdout data this.childProcess.stdout?.on('data', (data: Buffer) => { this.emit('stdout', data); }); // Handle stderr data this.childProcess.stderr?.on('data', (data: Buffer) => { this.emit('stderr', data); }); // Handle process exit this.childProcess.on('exit', (code, signal) => { this.logger.log(`Process exited with code: ${code}, signal: ${signal}`); this.emit('exit', code, signal); }); // Handle process errors this.childProcess.on('error', error => { this.logger.error('Process error:', error); this.emit('error', new ProcessSpawnError(error)); }); } /** * Wait for the process to be ready */ private async waitForReady(timeout: number): Promise<void> { await pollUntilTrue( () => { // Check if process is running and not killed if (!this.childProcess) { // Process not spawned yet return false; } if (this.childProcess.killed) { throw new Error('Process was killed during startup'); } // Process is running return !this.childProcess.killed; }, { timeout, // Check every 100ms or 1/10th of timeout interval: Math.min(100, timeout / 10), // Let process start before first check initialDelay: 50 }, 'Process startup timed out' ); // Additional wait for process to be fully ready (like the original logic) const stabilityWait = Math.min(500, timeout / 4); await new Promise<void>(resolve => setTimeout(resolve, stabilityWait)); // Final check that process is still running after stability wait if (!this.childProcess || this.childProcess.killed) { throw new Error('Process died during startup after initial success'); } } /** * Normalize execution options with defaults */ private normalizeExecutionOptions(options?: ExecutionOptions): Required<ExecutionOptions> { const DEFAULT_EXECUTION_OPTIONS = { timeout: 30000, functionName: 'main', allowNetwork: false, allowedDomains: [] as string[], allowEnv: false, allowRead: false, debugMode: false, retries: 0 }; return { timeout: options?.timeout ?? DEFAULT_EXECUTION_OPTIONS.timeout, functionName: options?.functionName ?? DEFAULT_EXECUTION_OPTIONS.functionName, allowNetwork: options?.allowNetwork ?? DEFAULT_EXECUTION_OPTIONS.allowNetwork, allowedDomains: options?.allowedDomains ?? [ ...DEFAULT_EXECUTION_OPTIONS.allowedDomains ], allowEnv: options?.allowEnv ?? DEFAULT_EXECUTION_OPTIONS.allowEnv, allowRead: options?.allowRead ?? DEFAULT_EXECUTION_OPTIONS.allowRead, debugMode: options?.debugMode ?? DEFAULT_EXECUTION_OPTIONS.debugMode, retries: options?.retries ?? DEFAULT_EXECUTION_OPTIONS.retries }; } /** * Extract permission configuration from execution options */ private extractPermissionConfig(options: Required<ExecutionOptions>): PermissionConfig { return { allowNetwork: options.allowNetwork, allowedDomains: options.allowedDomains, allowEnv: options.allowEnv, allowRead: options.allowRead }; } }