import { EventEmitter } from "events"; import IConfig from "./IConfig.js"; import * as rfb from 'rfb2'; import * as fs from 'fs'; import { execa, ExecaChildProcess } from "execa"; import QMPClient from "./QMPClient.js"; import BatchRects from "./RectBatcher.js"; import { createCanvas, Canvas, CanvasRenderingContext2D, createImageData } from "canvas"; import { Mutex } from "async-mutex"; import log from "./log.js"; export default class QEMUVM extends EventEmitter { vnc? : rfb.RfbClient; vncPort : number; framebuffer : Canvas; framebufferCtx : CanvasRenderingContext2D; qmpSock : string; qmpType: string; qmpClient : QMPClient; qemuCmd : string; qemuProcess? : ExecaChildProcess; qmpErrorLevel : number; vncErrorLevel : number; processRestartErrorLevel : number; expectedExit : boolean; vncOpen : boolean; vncUpdateInterval? : NodeJS.Timer; rects : {height:number,width:number,x:number,y:number,data:Buffer}[]; rectMutex : Mutex; vncReconnectTimeout? : NodeJS.Timer; qmpReconnectTimeout? : NodeJS.Timer; qemuRestartTimeout? : NodeJS.Timer; constructor(Config : IConfig) { super(); if (Config.vm.vncPort < 5900) { log("FATAL", "VNC port must be 5900 or higher") process.exit(1); } Config.vm.qmpSockDir == null ? this.qmpType = "tcp:" : this.qmpType = "unix:"; if(this.qmpType == "tcp:") { this.qmpSock = `${Config.vm.qmpHost}:${Config.vm.qmpPort}`; }else{ this.qmpSock = `${Config.vm.qmpSockDir}collab-vm-qmp-${Config.collabvm.node}.sock`; } this.vncPort = Config.vm.vncPort; this.qemuCmd = `${Config.vm.qemuArgs} -snapshot -no-shutdown -vnc 127.0.0.1:${this.vncPort - 5900} -qmp ${this.qmpType}${this.qmpSock},server`; this.qmpErrorLevel = 0; this.vncErrorLevel = 0; this.vncOpen = true; this.rects = []; this.rectMutex = new Mutex(); this.framebuffer = createCanvas(1, 1); this.framebufferCtx = this.framebuffer.getContext("2d"); this.processRestartErrorLevel = 0; this.expectedExit = false; this.qmpClient = new QMPClient(this.qmpSock, this.qmpType); this.qmpClient.on('connected', () => this.qmpConnected()); this.qmpClient.on('close', () => this.qmpClosed()); } Start() : Promise { return new Promise(async (res, rej) => { if (fs.existsSync(this.qmpSock)) try { fs.unlinkSync(this.qmpSock); } catch (e) { log("ERROR", `Failed to delete existing socket: ${e}`); process.exit(-1); } var qemuArr = this.qemuCmd.split(" "); this.qemuProcess = execa(qemuArr[0], qemuArr.slice(1)); this.qemuProcess.catch(() => false); this.qemuProcess.stderr?.on('data', (d) => log("ERROR", `QEMU sent to stderr: ${d.toString()}`)); this.qemuProcess.once('spawn', () => { setTimeout(async () => { await this.qmpClient.connect(); }, 2000) }); this.qemuProcess.once('exit', () => { if (this.expectedExit) return; clearTimeout(this.qmpReconnectTimeout); clearTimeout(this.vncReconnectTimeout); this.processRestartErrorLevel++; if (this.processRestartErrorLevel > 4) { log("FATAL", "QEMU failed to launch 5 times."); process.exit(-1); } log("WARN", "QEMU exited unexpectedly, retrying in 3 seconds"); this.qmpClient.disconnect(); this.vnc?.end(); this.qemuRestartTimeout = setTimeout(() => this.Start(), 3000); }); this.qemuProcess.on('error', () => false); this.once('vncconnect', () => res()); }); } private qmpConnected() { this.qmpErrorLevel = 0; this.processRestartErrorLevel = 0; log("INFO", "QMP Connected"); setTimeout(() => this.startVNC(), 1000); } private startVNC() { this.vnc = rfb.createConnection({ host: "127.0.0.1", port: this.vncPort, }); this.vnc.on("close", () => this.vncClosed()); this.vnc.on("connect", () => this.vncConnected()); this.vnc.on("rect", (r) => this.onVNCRect(r)); this.vnc.on("resize", (s) => this.onVNCSize(s)); } getSize() { if (!this.vnc) return {height:0,width:0}; return {height: this.vnc.height, width: this.vnc.width} } private qmpClosed() { if (this.expectedExit) return; this.qmpErrorLevel++; if (this.qmpErrorLevel > 4) { log("FATAL", "Failed to connect to QMP after 5 attempts"); process.exit(1); } log("ERROR", "Failed to connect to QMP, retrying in 3 seconds."); this.qmpReconnectTimeout = setTimeout(() => this.qmpClient.connect(), 3000); } private vncClosed() { this.vncOpen = false; if (this.expectedExit) return; this.vncErrorLevel++; if (this.vncErrorLevel > 4) { log("FATAL", "Failed to connect to VNC after 5 attempts.") process.exit(1); } try { this.vnc?.end(); } catch {}; log("ERROR", "Failed to connect to VNC, retrying in 3 seconds"); this.vncReconnectTimeout = setTimeout(() => this.startVNC(), 3000); } private vncConnected() { this.vncOpen = true; this.emit('vncconnect'); log("INFO", "VNC Connected"); this.vncErrorLevel = 0; //@ts-ignore this.onVNCSize({height: this.vnc.height, width: this.vnc.width}); this.vncUpdateInterval = setInterval(() => this.SendRects(), 33); } private onVNCRect(rect : any) { return this.rectMutex.runExclusive(async () => { return new Promise(async (res, rej) => { var buff = Buffer.alloc(rect.height * rect.width * 4) var offset = 0; for (var i = 0; i < rect.data.length; i += 4) { buff[offset++] = rect.data[i + 2]; buff[offset++] = rect.data[i + 1]; buff[offset++] = rect.data[i]; buff[offset++] = 255; } var imgdata = createImageData(Uint8ClampedArray.from(buff), rect.width, rect.height); this.framebufferCtx.putImageData(imgdata, rect.x, rect.y); this.rects.push({ x: rect.x, y: rect.y, height: rect.height, width: rect.width, data: buff, }); if (!this.vnc) throw new Error(); if (this.vncOpen) this.vnc.requestUpdate(true, 0, 0, this.vnc.height, this.vnc.width); res(); }) }); } SendRects() { if (!this.vnc || this.rects.length < 1) return; return this.rectMutex.runExclusive(() => { return new Promise(async (res, rej) => { var rect = await BatchRects(this.framebuffer, [...this.rects]); this.rects = []; this.emit('dirtyrect', rect.data, rect.x, rect.y); res(); }); }) } private onVNCSize(size : any) { if (this.framebuffer.height !== size.height) this.framebuffer.height = size.height; if (this.framebuffer.width !== size.width) this.framebuffer.width = size.width; this.emit("size", {height: size.height, width: size.width}); } Reboot() : Promise { return new Promise(async (res, rej) => { if (this.expectedExit) {res(); return;} res(await this.qmpClient.reboot()); }); } async Restore() { if (this.expectedExit) return; await this.Stop(); this.expectedExit = false; this.Start(); } Stop() : Promise { return new Promise(async (res, rej) => { if (this.expectedExit) {res(); return;} if (!this.qemuProcess) throw new Error("VM was not running"); this.expectedExit = true; this.vncOpen = false; this.vnc?.end(); clearInterval(this.vncUpdateInterval); var killTimeout = setTimeout(() => { log("WARN", "Force killing QEMU after 10 seconds of waiting for shutdown"); this.qemuProcess?.kill(9); }, 10000); var closep = new Promise(async (reso, reje) => { this.qemuProcess?.once('exit', () => reso()); await this.qmpClient.execute({ "execute": "quit" }); }); var qmpclosep = new Promise((reso, rej) => { this.qmpClient.once('close', () => reso()); }); await Promise.all([closep, qmpclosep]); clearTimeout(killTimeout); res(); }) } }