@comake/skl-js-engine
Version:
Standard Knowledge Language Javascript Engine
282 lines (242 loc) • 8.13 kB
text/typescript
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
};
}
}