This commit is contained in:
Lily Tsuru 2024-07-16 07:59:02 -04:00
commit 7ec901885d
13 changed files with 769 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.parcel-cache
.yarn/
dist/
node_modules
yarn.lock

20
.prettierrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": true,
"printWidth": 200,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "none",
"useTabs": true,
"vueIndentScriptAndStyle": false
}

1
.yarnrc.yml Normal file
View file

@ -0,0 +1 @@
nodeLinker: node-modules

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright 2023-2024 Lily Tsuru/modeco80 <lily.modeco80@protonmail.ch>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# superqemu
A QEMU supervision library for Node.js

25
examples/simple.js Normal file
View file

@ -0,0 +1,25 @@
// A simple example of how to use superqemu.
// Note that this example requires a valid desktop environment to function
// due to `-display gtk`, but you can remove it and run it headless.
import { QemuVM } from "../dist/index.js";
import pino from 'pino';
let logger = pino();
let vm = new QemuVM(
{
id: "testvm",
command: "qemu-system-x86_64 -M pc,hpet=off,accel=kvm -cpu host -m 512 -display gtk",
snapshot: true
}
);
vm.on('statechange', (newState) => {
logger.info(`state changed to ${newState}`);
});
(async () => {
await vm.Start();
})();

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@computenewb/superqemu",
"version": "1.0.0",
"description": "A simple and easy to use QEMU supervision runtime for Node.js",
"exports": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module",
"files": [
"./dist",
"LICENSE"
],
"scripts": {
"build": "parcel build src/index.ts --target node --target types"
},
"author": "",
"license": "MIT",
"targets": {
"types": {},
"node": {
"context": "node",
"isLibrary": true,
"outputFormat": "esmodule"
}
},
"dependencies": {
"@computernewb/nodejs-rfb": "^0.3.0",
"execa": "^8.0.1",
"pino": "^9.3.1"
},
"devDependencies": {
"@parcel/packager-ts": "2.12.0",
"@parcel/transformer-typescript-types": "2.12.0",
"@types/node": "^20.14.10",
"parcel": "^2.12.0",
"pino-pretty": "^11.2.1",
"typescript": ">=3.0.0"
},
"packageManager": "yarn@4.1.1"
}

150
src/QemuDisplay.ts Normal file
View file

@ -0,0 +1,150 @@
import { VncClient } from '@computernewb/nodejs-rfb';
import { EventEmitter } from 'node:events';
import { Size, Rect, Clamp, BatchRects } from './QemuUtil.js';
const kQemuFps = 60;
export type VncRect = {
x: number;
y: number;
width: number;
height: number;
};
// events:
//
// 'resize' -> (w, h) -> done when resize occurs
// 'rect' -> (x, y, ImageData) -> framebuffer
// 'frame' -> () -> done at end of frame
export class QemuDisplay extends EventEmitter {
private displayVnc = new VncClient({
debug: false,
fps: kQemuFps,
encodings: [
VncClient.consts.encodings.raw,
//VncClient.consts.encodings.pseudoQemuAudio,
VncClient.consts.encodings.pseudoDesktopSize
// For now?
//VncClient.consts.encodings.pseudoCursor
]
});
private vncShouldReconnect: boolean = false;
private vncSocketPath: string;
constructor(socketPath: string) {
super();
this.vncSocketPath = socketPath;
this.displayVnc.on('connectTimeout', () => {
this.Reconnect();
});
this.displayVnc.on('authError', () => {
this.Reconnect();
});
this.displayVnc.on('disconnect', () => {
this.Reconnect();
});
this.displayVnc.on('closed', () => {
this.Reconnect();
});
this.displayVnc.on('firstFrameUpdate', () => {
// apparently this library is this good.
// at least it's better than the two others which exist.
this.displayVnc.changeFps(kQemuFps);
this.emit('connected');
this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
//this.emit('rect', { x: 0, y: 0, width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
this.emit('frame');
});
this.displayVnc.on('desktopSizeChanged', (size: Size) => {
this.emit('resize', size);
});
let rects: Rect[] = [];
this.displayVnc.on('rectUpdateProcessed', (rect: Rect) => {
rects.push(rect);
});
this.displayVnc.on('frameUpdated', (fb: Buffer) => {
// use the cvmts batcher
let batched = BatchRects(this.Size(), rects);
this.emit('rect', batched);
// unbatched (watch the performace go now)
//for(let rect of rects)
// this.emit('rect', rect);
rects = [];
this.emit('frame');
});
}
private Reconnect() {
if (this.displayVnc.connected) return;
if (!this.vncShouldReconnect) return;
// TODO: this should also give up after a max tries count
// if we fail after max tries, emit a event
this.displayVnc.connect({
path: this.vncSocketPath
});
}
Connect() {
this.vncShouldReconnect = true;
this.Reconnect();
}
Disconnect() {
this.vncShouldReconnect = false;
this.displayVnc.disconnect();
// bye bye!
this.displayVnc.removeAllListeners();
this.removeAllListeners();
}
Connected() {
return this.displayVnc.connected;
}
Buffer(): Buffer {
return this.displayVnc.fb;
}
Size(): Size {
if (!this.displayVnc.connected)
return {
width: 0,
height: 0
};
return {
width: this.displayVnc.clientWidth,
height: this.displayVnc.clientHeight
};
}
MouseEvent(x: number, y: number, buttons: number) {
if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons);
}
KeyboardEvent(keysym: number, pressed: boolean) {
if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed);
}
}

55
src/QemuUtil.ts Normal file
View file

@ -0,0 +1,55 @@
export type Size = {
width: number;
height: number;
};
export type Rect = {
x: number;
y: number;
width: number;
height: number;
};
export function Clamp(input: number, min: number, max: number) {
return Math.min(Math.max(input, min), max);
}
export function BatchRects(size: Size, rects: Array<Rect>): Rect {
var mergedX = size.width;
var mergedY = size.height;
var mergedHeight = 0;
var mergedWidth = 0;
// can't batch these
if (rects.length == 0) {
return {
x: 0,
y: 0,
width: size.width,
height: size.height
};
}
if (rects.length == 1) {
if (rects[0].width == size.width && rects[0].height == size.height) {
return rects[0];
}
}
rects.forEach((r) => {
if (r.x < mergedX) mergedX = r.x;
if (r.y < mergedY) mergedY = r.y;
});
rects.forEach((r) => {
if (r.height + r.y - mergedY > mergedHeight) mergedHeight = r.height + r.y - mergedY;
if (r.width + r.x - mergedX > mergedWidth) mergedWidth = r.width + r.x - mergedX;
});
return {
x: mergedX,
y: mergedY,
width: mergedWidth,
height: mergedHeight
};
}

277
src/QemuVM.ts Normal file
View file

@ -0,0 +1,277 @@
import { execaCommand, ExecaChildProcess } from 'execa';
import { EventEmitter } from 'events';
import { QmpClient, IQmpClientWriter, QmpEvent } from './QmpClient.js';
import { QemuDisplay } from './QemuDisplay.js';
import { unlink } from 'node:fs/promises';
import pino from 'pino';
import { Readable, Writable } from 'stream';
export enum VMState {
Stopped,
Starting,
Started,
Stopping
}
export type QemuVmDefinition = {
id: string;
command: string;
snapshot: boolean;
};
/// 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: ExecaChildProcess | null = null;
private display: QemuDisplay | null = null;
private definition: QemuVmDefinition;
private addedAdditionalArguments = false;
private logger: pino.Logger;
constructor(def: QemuVmDefinition) {
super();
this.definition = def;
this.logger = pino({
name: `SuperQEMU.QemuVM/${this.definition.id}`
});
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');
this.display = new QemuDisplay(this.GetVncPath());
self.display?.on('connected', () => {
// The VM can now be considered started
self.logger.info('Display connected');
self.SetState(VMState.Started);
});
// now that QMP has connected, connect to the display
self.display?.Connect();
});
}
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 -vnc unix:${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() {
this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM');
// 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();
}
async Reset() {
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
await this.StopQemu();
}
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
});
}
GetDisplay() {
return this.display!;
}
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);
}
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 = execaCommand(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');
// Disconnect from the display and QMP connections.
await self.DisconnectDisplay();
self.qmpInstance.reset();
self.qmpInstance.setWriter(null);
// 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);
}
} else {
// Indicate we have stopped.
this.SetState(VMState.Stopped);
}
});
}
private async StopQemu() {
if (this.qemuProcess) {
this.qemuProcess?.kill('SIGTERM');
this.qemuProcess = null;
}
}
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);
}
private async DisconnectDisplay() {
try {
this.display?.Disconnect();
this.display = null;
} catch (err) {
// oh well lol
}
}
}

170
src/QmpClient.ts Normal file
View file

@ -0,0 +1,170 @@
import { EventEmitter } from 'node:events';
enum QmpClientState {
Handshaking,
Connected
}
function qmpStringify(obj: any) {
return JSON.stringify(obj) + '\r\n';
}
// this writer interface is used to poll back to a higher level
// I/O layer that we want to write some data.
export interface IQmpClientWriter {
writeSome(data: Buffer): void;
}
export type QmpClientCallback = (err: Error | null, res: any | null) => void;
type QmpClientCallbackEntry = {
id: number;
callback: QmpClientCallback | null;
};
export enum QmpEvent {
BlockIOError = 'BLOCK_IO_ERROR',
Reset = 'RESET',
Resume = 'RESUME',
RtcChange = 'RTC_CHANGE',
Shutdown = 'SHUTDOWN',
Stop = 'STOP',
VncConnected = 'VNC_CONNECTED',
VncDisconnected = 'VNC_DISCONNECTED',
VncInitialized = 'VNC_INITIALIZED',
Watchdog = 'WATCHDOG'
}
class LineStream extends EventEmitter {
// The given line seperator for the stream
lineSeperator = '\r\n';
buffer = '';
constructor() {
super();
}
push(data: Buffer) {
this.buffer += data.toString('utf-8');
let lines = this.buffer.split(this.lineSeperator);
if (lines.length > 1) {
this.buffer = lines.pop()!;
lines = lines.filter((l) => !!l);
lines.forEach((l) => this.emit('line', l));
}
}
reset() {
this.buffer = '';
}
}
// A QMP client
export class QmpClient extends EventEmitter {
private state = QmpClientState.Handshaking;
private writer: IQmpClientWriter | null = null;
private lastID = 0;
private callbacks = new Array<QmpClientCallbackEntry>();
private lineStream = new LineStream();
constructor() {
super();
let self = this;
this.lineStream.on('line', (line: string) => {
self.handleQmpLine(line);
});
}
setWriter(writer: IQmpClientWriter | null) {
this.writer = writer;
}
feed(data: Buffer): void {
// Forward to the line stream. It will generate 'line' events
// as it is able to split out lines automatically.
this.lineStream.push(data);
}
private handleQmpLine(line: string) {
let obj = JSON.parse(line);
switch (this.state) {
case QmpClientState.Handshaking:
if (obj['return'] != undefined) {
// Once we get a return from our handshake execution,
// we have exited handshake state.
this.state = QmpClientState.Connected;
this.emit('connected');
return;
} else if (obj['QMP'] != undefined) {
// Send a `qmp_capabilities` command, to exit handshake state.
// We do not support any of the supported extended QMP capabilities currently,
// and probably never will (due to their relative uselessness.)
let capabilities = qmpStringify({
execute: 'qmp_capabilities'
});
this.writer?.writeSome(Buffer.from(capabilities, 'utf8'));
}
break;
case QmpClientState.Connected:
if (obj['return'] != undefined || obj['error'] != undefined) {
if (obj['id'] == null) return;
let cb = this.callbacks.find((v) => v.id == obj['id']);
if (cb == undefined) return;
let error: Error | null = obj.error ? new Error(obj.error.desc) : null;
if (cb.callback) cb.callback(error, obj.return || null);
this.callbacks.slice(this.callbacks.indexOf(cb));
} else if (obj['event']) {
this.emit(obj.event, {
timestamp: obj.timestamp,
data: obj.data
});
}
break;
}
}
// Executes a QMP command, using a user-provided callback for completion notification
executeCallback(command: string, args: any | undefined, callback: QmpClientCallback | null) {
let entry = {
callback: callback,
id: ++this.lastID
};
let qmpOut: any = {
execute: command,
id: entry.id
};
if (args !== undefined) qmpOut['arguments'] = args;
this.callbacks.push(entry);
this.writer?.writeSome(Buffer.from(qmpStringify(qmpOut), 'utf8'));
}
// Executes a QMP command asynchronously.
async execute(command: string, args: any | undefined = undefined): Promise<any> {
return new Promise((res, rej) => {
this.executeCallback(command, args, (err, result) => {
if (err) rej(err);
res(result);
});
});
}
reset() {
// Reset the line stream so it doesn't go awry
this.lineStream.reset();
this.state = QmpClientState.Handshaking;
}
}

4
src/index.ts Normal file
View file

@ -0,0 +1,4 @@
export * from './QemuDisplay.js';
export * from './QemuUtil.js';
export * from './QemuVM.js';
export * from './QmpClient.js';

1
tsconfig.json Symbolic link
View file

@ -0,0 +1 @@
../tsconfig.json