2026-02-08 21:47:33 +00:00
|
|
|
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';
|
|
|
|
|
|
2026-05-01 13:30:51 +00:00
|
|
|
type TStreamingResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawnStreaming']>>;
|
|
|
|
|
type TExecResult = Awaited<TStreamingResult['finalPromise']>;
|
|
|
|
|
|
|
|
|
|
function getErrorMessage(err: unknown): string {
|
|
|
|
|
return err instanceof Error ? err.message : String(err);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 21:47:33 +00:00
|
|
|
/**
|
|
|
|
|
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
|
|
|
|
|
*/
|
|
|
|
|
export class FirecrackerProcess {
|
|
|
|
|
private options: IFirecrackerProcessOptions;
|
2026-05-01 13:30:51 +00:00
|
|
|
private streaming: TStreamingResult | null = null;
|
2026-02-08 21:47:33 +00:00
|
|
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
|
|
|
|
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
|
2026-05-01 13:30:51 +00:00
|
|
|
private lastExitResult: TExecResult | null = null;
|
|
|
|
|
private lastExitError: string | null = null;
|
2026-02-08 21:47:33 +00:00
|
|
|
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<void> {
|
|
|
|
|
// Remove any stale socket file
|
|
|
|
|
if (plugins.fs.existsSync(this.options.socketPath)) {
|
|
|
|
|
plugins.fs.unlinkSync(this.options.socketPath);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 13:30:51 +00:00
|
|
|
// Build the command args without a shell so paths are not interpreted.
|
|
|
|
|
const args = ['--api-sock', this.options.socketPath];
|
2026-02-08 21:47:33 +00:00
|
|
|
if (this.options.logLevel) {
|
2026-05-01 13:30:51 +00:00
|
|
|
args.push('--level', this.options.logLevel);
|
2026-02-08 21:47:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spawn the process
|
2026-05-01 13:30:51 +00:00
|
|
|
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);
|
|
|
|
|
});
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
// 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) {
|
2026-05-01 13:30:51 +00:00
|
|
|
const wasRunning = this.isRunning();
|
|
|
|
|
const diagnostics = this.formatDiagnostics();
|
2026-02-08 21:47:33 +00:00
|
|
|
await this.stop();
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Firecracker socket did not become ready within timeout${diagnostics || (wasRunning ? '' : this.formatDiagnostics())}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'SOCKET_TIMEOUT',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for the API to be responsive
|
|
|
|
|
const apiReady = await this.socketClient.isReady(5000);
|
|
|
|
|
if (!apiReady) {
|
2026-05-01 13:30:51 +00:00
|
|
|
const diagnostics = this.formatDiagnostics();
|
2026-02-08 21:47:33 +00:00
|
|
|
await this.stop();
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Firecracker API did not become responsive within timeout${diagnostics}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'API_TIMEOUT',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Poll for the socket file to appear on disk.
|
|
|
|
|
*/
|
|
|
|
|
private async waitForSocket(timeoutMs: number): Promise<boolean> {
|
|
|
|
|
const start = Date.now();
|
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
|
|
|
if (plugins.fs.existsSync(this.options.socketPath)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-01 13:30:51 +00:00
|
|
|
if (this.streaming && !this.isRunning()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-08 21:47:33 +00:00
|
|
|
await plugins.smartdelay.delayFor(100);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 13:30:51 +00:00
|
|
|
private async waitForExit(streaming: TStreamingResult, timeoutMs: number): Promise<boolean> {
|
|
|
|
|
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 '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 21:47:33 +00:00
|
|
|
/**
|
|
|
|
|
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
|
|
|
|
|
*/
|
|
|
|
|
public async stop(): Promise<void> {
|
2026-05-01 13:30:51 +00:00
|
|
|
const streaming = this.streaming;
|
|
|
|
|
if (!streaming) return;
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Try graceful termination first
|
2026-05-01 13:30:51 +00:00
|
|
|
await streaming.terminate();
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
// Wait up to 5 seconds for the process to exit
|
2026-05-01 13:30:51 +00:00
|
|
|
const terminated = await this.waitForExit(streaming, 5000);
|
|
|
|
|
if (!terminated) {
|
|
|
|
|
await streaming.kill();
|
|
|
|
|
await this.waitForExit(streaming, 1000);
|
|
|
|
|
}
|
2026-02-08 21:47:33 +00:00
|
|
|
} catch {
|
|
|
|
|
// If termination fails, force kill
|
|
|
|
|
try {
|
2026-05-01 13:30:51 +00:00
|
|
|
await streaming.kill();
|
|
|
|
|
await this.waitForExit(streaming, 1000);
|
2026-02-08 21:47:33 +00:00
|
|
|
} catch {
|
|
|
|
|
// Process may already be dead
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 13:30:51 +00:00
|
|
|
if (this.smartExitInstance) {
|
|
|
|
|
this.smartExitInstance.removeProcess(streaming.childProcess);
|
|
|
|
|
this.smartExitInstance = null;
|
|
|
|
|
}
|
2026-02-08 21:47:33 +00:00
|
|
|
this.streaming = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clean up the socket file.
|
|
|
|
|
*/
|
|
|
|
|
public async cleanup(): Promise<void> {
|
|
|
|
|
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 {
|
2026-05-01 13:30:51 +00:00
|
|
|
const pid = this.streaming?.childProcess?.pid;
|
|
|
|
|
if (!pid) return false;
|
2026-02-08 21:47:33 +00:00
|
|
|
try {
|
|
|
|
|
// Sending signal 0 tests if process exists without actually sending a signal
|
2026-05-01 13:30:51 +00:00
|
|
|
process.kill(pid, 0);
|
2026-02-08 21:47:33 +00:00
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the child process PID.
|
|
|
|
|
*/
|
|
|
|
|
public getPid(): number | null {
|
|
|
|
|
return this.streaming?.childProcess?.pid ?? null;
|
|
|
|
|
}
|
|
|
|
|
}
|