import * as plugins from './plugins.js'; import type { IFirecrackerProcessOptions } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js'; import { SocketClient } from './classes.socketclient.js'; type TStreamingResult = Awaited['execSpawnStreaming']>>; type TExecResult = Awaited; function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } /** * Manages a single Firecracker child process, including startup, readiness polling, and shutdown. */ export class FirecrackerProcess { private options: IFirecrackerProcessOptions; private streaming: TStreamingResult | null = null; private shell: InstanceType; private smartExitInstance: InstanceType | null = null; private lastExitResult: TExecResult | null = null; private lastExitError: string | null = null; public socketClient: SocketClient; constructor(options: IFirecrackerProcessOptions) { this.options = options; this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); this.socketClient = new SocketClient({ socketPath: options.socketPath }); } /** * Start the Firecracker process and wait for the API socket to become ready. */ public async start(): Promise { // Remove any stale socket file if (plugins.fs.existsSync(this.options.socketPath)) { plugins.fs.unlinkSync(this.options.socketPath); } // Build the command args without a shell so paths are not interpreted. const args = ['--api-sock', this.options.socketPath]; if (this.options.logLevel) { args.push('--level', this.options.logLevel); } // Spawn the process this.streaming = await this.shell.execSpawnStreaming(this.options.binaryPath, args, { silent: true }); this.streaming.finalPromise .then((result) => { this.lastExitResult = result; }) .catch((err) => { this.lastExitError = getErrorMessage(err); }); // Register with smartexit for automatic cleanup if (this.streaming?.childProcess) { this.smartExitInstance = new plugins.smartexit.SmartExit({ silent: true }); this.smartExitInstance.addProcess(this.streaming.childProcess); } // Wait for the socket file to appear const socketReady = await this.waitForSocket(10000); if (!socketReady) { const wasRunning = this.isRunning(); const diagnostics = this.formatDiagnostics(); await this.stop(); throw new SmartVMError( `Firecracker socket did not become ready within timeout${diagnostics || (wasRunning ? '' : this.formatDiagnostics())}`, 'SOCKET_TIMEOUT', ); } // Wait for the API to be responsive const apiReady = await this.socketClient.isReady(5000); if (!apiReady) { const diagnostics = this.formatDiagnostics(); await this.stop(); throw new SmartVMError( `Firecracker API did not become responsive within timeout${diagnostics}`, 'API_TIMEOUT', ); } } /** * Poll for the socket file to appear on disk. */ private async waitForSocket(timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (plugins.fs.existsSync(this.options.socketPath)) { return true; } if (this.streaming && !this.isRunning()) { return false; } await plugins.smartdelay.delayFor(100); } return false; } private async waitForExit(streaming: TStreamingResult, timeoutMs: number): Promise { return Promise.race([ streaming.finalPromise.then((result) => { this.lastExitResult = result; return true; }).catch((err) => { this.lastExitError = getErrorMessage(err); return true; }), plugins.smartdelay.delayFor(timeoutMs).then(() => false), ]); } private formatDiagnostics(): string { if (this.lastExitError) { return `: ${this.lastExitError}`; } if (this.lastExitResult) { const output = (this.lastExitResult.stderr || this.lastExitResult.stdout || '').trim(); return `: process exited with code ${this.lastExitResult.exitCode}${output ? `: ${output}` : ''}`; } return ''; } /** * Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout. */ public async stop(): Promise { const streaming = this.streaming; if (!streaming) return; try { // Try graceful termination first await streaming.terminate(); // Wait up to 5 seconds for the process to exit const terminated = await this.waitForExit(streaming, 5000); if (!terminated) { await streaming.kill(); await this.waitForExit(streaming, 1000); } } catch { // If termination fails, force kill try { await streaming.kill(); await this.waitForExit(streaming, 1000); } catch { // Process may already be dead } } if (this.smartExitInstance) { this.smartExitInstance.removeProcess(streaming.childProcess); this.smartExitInstance = null; } this.streaming = null; } /** * Clean up the socket file. */ public async cleanup(): Promise { await this.stop(); // Remove the socket file if (plugins.fs.existsSync(this.options.socketPath)) { plugins.fs.unlinkSync(this.options.socketPath); } } /** * Check if the process is currently running. */ public isRunning(): boolean { const pid = this.streaming?.childProcess?.pid; if (!pid) return false; try { // Sending signal 0 tests if process exists without actually sending a signal process.kill(pid, 0); return true; } catch { return false; } } /** * Get the child process PID. */ public getPid(): number | null { return this.streaming?.childProcess?.pid ?? null; } }