init
This commit is contained in:
commit
7ec901885d
13 changed files with 769 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.parcel-cache
|
||||||
|
.yarn/
|
||||||
|
dist/
|
||||||
|
node_modules
|
||||||
|
yarn.lock
|
20
.prettierrc.json
Normal file
20
.prettierrc.json
Normal 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
1
.yarnrc.yml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
nodeLinker: node-modules
|
19
LICENSE
Normal file
19
LICENSE
Normal 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
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# superqemu
|
||||||
|
|
||||||
|
A QEMU supervision library for Node.js
|
25
examples/simple.js
Normal file
25
examples/simple.js
Normal 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
39
package.json
Normal 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
150
src/QemuDisplay.ts
Normal 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
55
src/QemuUtil.ts
Normal 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
277
src/QemuVM.ts
Normal 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
170
src/QmpClient.ts
Normal 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
4
src/index.ts
Normal 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
1
tsconfig.json
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../tsconfig.json
|
Loading…
Reference in a new issue