superqemu/src/QemuVM.ts
modeco80 2aeba659ed also wait for reset
(move null assignment to after the process is definitely killed, so that we can actually perform the dispose op)

v0.3.1
2024-11-02 11:44:49 -04:00

337 lines
8.8 KiB
TypeScript

import { execaCommand, ExecaChildProcess } from 'execa';
import { EventEmitter } from 'events';
import { QmpClient, IQmpClientWriter, QmpEvent } from './QmpClient.js';
import { unlink } from 'node:fs/promises';
import pino from 'pino';
import { Readable, Writable } from 'stream';
import { IProcess, IProcessLauncher } from './ProcessInterface.js';
import { DefaultProcessLauncher } from './DefaultProcess.js';
export enum VMState {
Stopped,
Starting,
Started,
Stopping
}
/// VM definition.
export type QemuVmDefinition = {
id: string;
command: string;
snapshot: boolean;
forceTcp: boolean;
vncHost: string | undefined;
vncPort: number | undefined;
};
/// Display information.
export interface QemuVMDisplayInfo {
type: 'vnc-uds' | 'vnc-tcp';
// 'vnc-uds'
path?: string;
// 'vnc-tcp'
host?: string;
port?: number;
}
/// Temporary path base (for UNIX sockets/etc.)
const kVmTmpPathBase = `/tmp`;
// writer implementation for process standard I/O
class StdioWriter implements IQmpClientWriter {
stdout;
stdin;
client;
constructor(stdout: Readable, stdin: Writable, client: QmpClient) {
this.stdout = stdout;
this.stdin = stdin;
this.client = client;
this.stdout.on('data', (data) => {
this.client.feed(data);
});
}
writeSome(buffer: Buffer) {
if (!this.stdin.closed) this.stdin.write(buffer);
}
}
export declare interface QemuVM {
on(event: 'statechange', listener: (newState: VMState) => void): this;
}
export class QemuVM extends EventEmitter {
private state = VMState.Stopped;
// QMP stuff.
private qmpInstance: QmpClient = new QmpClient();
private qemuProcess: IProcess | null = null;
private qemuLauncher: IProcessLauncher;
private displayInfo: QemuVMDisplayInfo | null = null;
private definition: QemuVmDefinition;
private addedAdditionalArguments = false;
private logger: pino.Logger;
constructor(def: QemuVmDefinition, processLauncher?: IProcessLauncher) {
super();
this.definition = def;
this.logger = pino({
name: `SuperQEMU.QemuVM/${this.definition.id}`
});
// Fall back to the default process launcher. This is
// done so we can have our cake (compatibility) and eat it too
// (do this fun process abstraction stuff for whatever really)
if (!processLauncher) {
this.qemuLauncher = new DefaultProcessLauncher();
} else {
this.qemuLauncher = processLauncher;
}
let self = this;
// Handle the STOP event sent when using -no-shutdown
this.qmpInstance.on(QmpEvent.Stop, async () => {
await self.qmpInstance.execute('system_reset');
});
this.qmpInstance.on(QmpEvent.Reset, async () => {
await self.qmpInstance.execute('cont');
});
this.qmpInstance.on('connected', async () => {
self.logger.info('QMP ready');
self.SetState(VMState.Started);
});
}
async Start() {
// Don't start while either trying to start or starting.
//if (this.state == VMState.Started || this.state == VMState.Starting) return;
if (this.qemuProcess) return;
let cmd = this.definition.command;
// Build additional command line statements to enable qmp/vnc over unix sockets
if (!this.addedAdditionalArguments) {
cmd += ' -no-shutdown';
if (this.definition.snapshot) cmd += ' -snapshot';
cmd += ` -qmp stdio`;
if (this.definition.forceTcp || process.platform === 'win32') {
let host = this.definition.vncHost || '127.0.0.1';
let port = this.definition.vncPort || 5900;
if (port < 5900) {
throw new Error('VNC port must be greater than or equal to 5900');
}
cmd += ` -vnc ${host}:${port - 5900}`;
this.displayInfo = {
type: 'vnc-tcp',
host: host,
port: port
};
} else {
cmd += ` -vnc unix:${this.GetVncPath()}`;
this.displayInfo = {
type: 'vnc-uds',
path: this.GetVncPath()
};
}
this.definition.command = cmd;
this.addedAdditionalArguments = true;
}
await this.StartQemu(cmd);
}
SnapshotsSupported(): boolean {
return this.definition.snapshot;
}
async Reboot(): Promise<void> {
await this.MonitorCommand('system_reset');
}
async Stop(): Promise<void> {
this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM');
// I'm not sure this is better, but I'm also not sure it should be an assertion
//if(this.state !== VMState.Started)
// return;
// Indicate we're stopping, so we don't erroneously start trying to restart everything we're going to tear down.
this.SetState(VMState.Stopping);
// Stop the QEMU process, which will bring down everything else.
await this.StopQemu();
// Wait for the VM to reach the stopped state.
return new Promise((res, rej) => {
this.once('statechange', (state) => {
if (state == VMState.Stopped) res();
});
});
}
async Reset(): Promise<void> {
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
await this.StopQemu();
let self = this;
// Wait for the VM to regain the started state
return new Promise((res, rej) => {
let cb = (state: VMState) => {
if (state == VMState.Started) {
this.removeListener('statechange', cb);
res();
}
};
this.on('statechange', cb);
});
}
async QmpCommand(command: string, args: any | null): Promise<any> {
return await this.qmpInstance?.execute(command, args);
}
async MonitorCommand(command: string) {
this.AssertState(VMState.Started, 'cannot use QemuVM#MonitorCommand on a non-started VM');
let result = await this.QmpCommand('human-monitor-command', {
'command-line': command
});
if (result == null) result = '';
return result;
}
async ChangeRemovableMedia(deviceName: string, imagePath: string): Promise<void> {
this.AssertState(VMState.Started, 'cannot use QemuVM#ChangeRemovableMedia on a non-started VM');
// N.B: if this throws, the code which called this should handle the error accordingly
await this.QmpCommand('blockdev-change-medium', {
device: deviceName, // techinically deprecated, but I don't feel like figuring out QOM path just for a simple function
filename: imagePath
});
}
async EjectRemovableMedia(deviceName: string) {
this.AssertState(VMState.Started, 'cannot use QemuVM#EjectRemovableMedia on a non-started VM');
await this.QmpCommand('eject', {
device: deviceName
});
}
// Gets the required information to connect to the VNC server
// that Superqemu enables by adding arguments to the QEMU launch
// command. Superqemu no longer directly has a VNC client.
//
// This is only null if the VM has never been started.
GetDisplayInfo() {
return this.displayInfo;
}
GetState() {
return this.state;
}
/// Private fun bits :)
private VMLog() {
return this.logger;
}
private AssertState(stateShouldBe: VMState, message: string) {
if (this.state !== stateShouldBe) throw new Error(message);
}
private SetState(state: VMState) {
this.state = state;
this.emit('statechange', this.state);
}
// No longer internal
private GetVncPath() {
return `${kVmTmpPathBase}/superqemu-${this.definition.id}-vnc`;
}
private async StartQemu(split: string) {
let self = this;
this.SetState(VMState.Starting);
this.logger.info(`Starting QEMU with command \"${split}\"`);
// Start QEMU
this.qemuProcess = this.qemuLauncher.launch(split, {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe'
});
this.qemuProcess.stderr?.on('data', (data) => {
self.logger.error(`QEMU stderr: ${data.toString('utf8')}`);
});
this.qemuProcess.on('spawn', async () => {
self.logger.info('QEMU started');
await self.QmpStdioInit();
});
this.qemuProcess.on('exit', async (code) => {
self.logger.info('QEMU process exited');
// Dispose events. StartQemu() will assign them again to the new process.
// A bit mucky but /shrug.
self.qemuProcess?.dispose();
self.qemuProcess = null;
self.qmpInstance.reset();
self.qmpInstance.setWriter(null);
if (!this.definition.forceTcp || process.platform !== 'win32') {
// Remove the VNC UDS socket.
try {
await unlink(this.GetVncPath());
} catch (_) {}
}
if (self.state != VMState.Stopping) {
if (code == 0) {
await self.StartQemu(split);
} else {
self.logger.error('QEMU exited with a non-zero exit code. This usually means an error in the command line. Stopping VM.');
// Note that we've already tore down everything upon entry to this event handler; therefore
// we can simply set the state and move on.
this.SetState(VMState.Stopped);
self.qemuProcess = null;
}
} else {
// Indicate we have stopped.
this.SetState(VMState.Stopped);
self.qemuProcess = null;
}
});
}
private async StopQemu() {
if (this.qemuProcess) {
this.qemuProcess?.kill('SIGTERM');
}
}
private async QmpStdioInit() {
let self = this;
self.logger.info('Initializing QMP over stdio');
// Setup the QMP client.
let writer = new StdioWriter(this.qemuProcess?.stdout!, this.qemuProcess?.stdin!, self.qmpInstance);
self.qmpInstance.reset();
self.qmpInstance.setWriter(writer);
}
}