Files
collabvm-1.2.ts/qemu/src/QemuVM.ts
modeco80 cb297e15c4 Giant refactoring (or at least the start)
In short:
- cvmts is now bundled/built via parcel and inside of a npm/yarn workspace with multiple nodejs projects
- cvmts now uses the crusttest QEMU management and RFB library (or a fork, if you so prefer).
- cvmts does NOT use node-canvas anymore, instead we opt for the same route crusttest took and just encode jpegs ourselves from the RFB provoded framebuffer via jpeg-turbo. this means funnily enough sharp is back for more for thumbnails, but actually seems to WORK this time
- IPData is now managed in a very similar way to the original cvm 1.2 implementation where a central manager and reference count exist. tbh it wouldn't be that hard to implement multinode either, but for now, I'm not going to take much time on doing that.

this refactor is still incomplete. please do not treat it as generally available while it's not on the default branch. if you want to use it (and report bugs or send fixes) feel free to, but while it may "just work" in certain situations it may be very broken in others.

(yes, I know windows support is partially totaled by this; it's something that can and will be fixed)
2024-04-23 09:57:02 -04:00

291 lines
7.6 KiB
TypeScript

import { execa, execaCommand, ExecaChildProcess } from 'execa';
import { EventEmitter } from 'events';
import QmpClient from './QmpClient.js';
import { QemuDisplay } from './QemuDisplay.js';
import { unlink } from 'node:fs/promises';
import * as Shared from '@cvmts/shared';
export enum VMState {
Stopped,
Starting,
Started,
Stopping
}
// TODO: Add bits to this to allow usage (optionally)
// of VNC/QMP port. This will be needed to fix up Windows support.
export type QemuVmDefinition = {
id: string;
command: string;
};
/// Temporary path base (for UNIX sockets/etc.)
const kVmTmpPathBase = `/tmp`;
/// The max amount of times QMP connection is allowed to fail before
/// the VM is forcefully stopped.
const kMaxFailCount = 5;
// TODO: This should be added to QemuVmDefinition and the below export removed
let gVMShouldSnapshot = true;
export function setSnapshot(val: boolean) {
gVMShouldSnapshot = val;
}
export class QemuVM extends EventEmitter {
private state = VMState.Stopped;
private qmpInstance: QmpClient | null = null;
private qmpConnected = false;
private qmpFailCount = 0;
private qemuProcess: ExecaChildProcess | null = null;
private qemuRunning = false;
private display: QemuDisplay;
private definition: QemuVmDefinition;
private addedAdditionalArguments = false;
private logger: Shared.Logger;
constructor(def: QemuVmDefinition) {
super();
this.definition = def;
this.logger = new Shared.Logger(`CVMTS.QEMU.QemuVM/${this.definition.id}`);
this.display = new QemuDisplay(this.GetVncPath());
}
async Start() {
// Don't start while either trying to start or starting.
if (this.state == VMState.Started || this.state == VMState.Starting) return;
let cmd = this.definition.command;
// build additional command line statements to enable qmp/vnc over unix sockets
// FIXME: Still use TCP if on Windows.
if(!this.addedAdditionalArguments) {
cmd += ' -no-shutdown';
if(gVMShouldSnapshot)
cmd += ' -snapshot';
cmd += ` -qmp unix:${this.GetQmpPath()},server,wait -vnc unix:${this.GetVncPath()}`;
this.definition.command = cmd;
this.addedAdditionalArguments = true;
}
this.VMLog().Info(`Starting QEMU with command \"${cmd}\"`);
await this.StartQemu(cmd);
}
async Stop() {
// This is called in certain lifecycle places where we can't safely assert state yet
//this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM');
// Start indicating we're stopping, so we don't
// erroneously start trying to restart everything
// we're going to tear down in this function call.
this.SetState(VMState.Stopping);
// Kill the QEMU process and QMP/display connections if they are running.
await this.DisconnectQmp();
this.DisconnectDisplay();
await this.StopQemu();
}
async Reset() {
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
// let code know the VM is going to reset
// N.B: In the crusttest world, a reset simply amounts to a
// mean cold reboot of the qemu process basically
this.emit('reset');
await this.Stop();
await Shared.Sleep(500);
await this.Start();
}
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');
return await this.QmpCommand('human-monitor-command', {
'command-line': command
});
}
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
});
}
GetDisplay() {
return this.display;
}
/// 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);
}
private GetQmpPath() {
return `${kVmTmpPathBase}/cvmts-${this.definition.id}-mon`;
}
private GetVncPath() {
return `${kVmTmpPathBase}/cvmts-${this.definition.id}-vnc`;
}
private async StartQemu(split: string) {
let self = this;
this.SetState(VMState.Starting);
// Start QEMU
this.qemuProcess = execaCommand(split);
this.qemuProcess.on('spawn', async () => {
self.qemuRunning = true;
await Shared.Sleep(500);
await self.ConnectQmp();
});
this.qemuProcess.on('exit', async (code) => {
self.qemuRunning = false;
// ?
if (self.qmpConnected) {
await self.DisconnectQmp();
}
self.DisconnectDisplay();
if (self.state != VMState.Stopping) {
if (code == 0) {
await Shared.Sleep(500);
await self.StartQemu(split);
} else {
self.VMLog().Error('QEMU exited with a non-zero exit code. This usually means an error in the command line. Stopping VM.');
await self.Stop();
}
} else {
this.SetState(VMState.Stopped);
}
});
}
private async StopQemu() {
if (this.qemuRunning == true) this.qemuProcess?.kill('SIGTERM');
}
private async ConnectQmp() {
let self = this;
if (!this.qmpConnected) {
self.qmpInstance = new QmpClient();
self.qmpInstance.on('close', async () => {
self.qmpConnected = false;
// If we aren't stopping, then we do actually need to care QMP disconnected
if (self.state != VMState.Stopping) {
if (self.qmpFailCount++ < kMaxFailCount) {
this.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times`);
await Shared.Sleep(500);
await self.ConnectQmp();
} else {
this.VMLog().Error(`Failed to connect to QMP ${self.qmpFailCount} times, giving up`);
await self.Stop();
}
}
});
self.qmpInstance.on('event', async (ev) => {
switch (ev.event) {
// Handle the STOP event sent when using -no-shutdown
case 'STOP':
await self.qmpInstance?.Execute('system_reset');
break;
case 'RESET':
await self.qmpInstance?.Execute('cont');
break;
}
});
self.qmpInstance.on('qmp-ready', async (hadError) => {
self.VMLog().Info('QMP ready');
self.display.Connect();
// QMP has been connected so the VM is ready to be considered started
self.qmpFailCount = 0;
self.qmpConnected = true;
self.SetState(VMState.Started);
});
try {
await Shared.Sleep(500);
this.qmpInstance?.ConnectUNIX(this.GetQmpPath());
} catch (err) {
// just try again
await Shared.Sleep(500);
await this.ConnectQmp();
}
}
}
private async DisconnectDisplay() {
try {
this.display?.Disconnect();
//this.display = null; // disassociate with that display object.
await unlink(this.GetVncPath());
// qemu *should* do this on its own but it really doesn't like doing so sometimes
await unlink(this.GetQmpPath());
} catch (err) {
// oh well lol
}
}
private async DisconnectQmp() {
if (this.qmpConnected) return;
if(this.qmpInstance == null)
return;
this.qmpConnected = false;
this.qmpInstance.end();
this.qmpInstance = null;
try {
await unlink(this.GetQmpPath());
} catch(err) {
}
}
}