Compare commits

...

9 commits

Author SHA1 Message Date
84b4b51e82 npm sucks 2024-11-02 11:51:28 -04:00
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
d4b316962a Wait for the state to become stopped in QemuVM#Stop 2024-11-02 11:35:47 -04:00
82065e2899 oops 2024-11-02 03:07:21 -04:00
4b4d42b1ed release v0.3.0 2024-11-02 03:04:23 -04:00
77277e7e61 export process interface things 2024-11-02 03:01:50 -04:00
adcefd76ef retool QemuVM to use the process launcher interface
Relatively easy since we basically comform to a subset of execa anyways.
2024-11-02 03:00:51 -04:00
d19f649a55 add a interface for process launching
This is to allow external usage of the library to control process launching (and, for one example, place processes we launch into special cgroups.)
2024-11-02 02:52:43 -04:00
e3123252a4 release 0.2.4
Please see the release notes for additional information regarding this superqemu release.
2024-08-23 09:58:08 -04:00
6 changed files with 175 additions and 11 deletions

27
RELEASE_NOTES.md Normal file
View file

@ -0,0 +1,27 @@
# Superqemu Release Notes
## `v0.3.1`
This minor release fixes a bug that would result in the process interface's `dispose()` method never actually being called.
This version is published as `0.3.2` because the npm registry is the most utterly useless piece of garbage I've ever used. This sentiment tends to echo for much of the JavaScript ecosystem. Almost like shoehorning a language meant literally for DHTML clocks and other stuff into being a backend language wasn't a great idea or something...
## `v0.3.0`
This release contains *possibly* breaking changes:
Superqemu now uses a interface to launch and interact with the QEMU process.
This is intended to allow for an external user of superqemu to perform resource control on the QEMU process, which previously was pretty much impossible.
The library does not enforce this and for compatibility with previous versions of superqemu the process launcher argument in QemuVM is optional.
## `v0.2.4`
This release contans breaking changes:
- Superqemu no longer depends on nodejs-rfb, or provides its own VNC client support. Instead, it still sets up VNC in QEMU, and it provides the required information to connect, but allows you the control to connect to the VNC server QEMU has setup yourself.
## `v0.2.3`
- TCP support

View file

@ -1,13 +1,15 @@
{ {
"name": "@computernewb/superqemu", "name": "@computernewb/superqemu",
"version": "0.2.4-alpha0", "version": "0.3.2",
"description": "A simple and easy to use QEMU supervision runtime for Node.js", "description": "A simple and easy to use QEMU supervision runtime for Node.js",
"exports": "./dist/index.js", "exports": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"type": "module", "type": "module",
"files": [ "files": [
"./dist", "./dist",
"LICENSE" "LICENSE",
"README.md",
"RELEASE_NOTES.md"
], ],
"scripts": { "scripts": {
"build": "parcel build src/index.ts --target node --target types" "build": "parcel build src/index.ts --target node --target types"
@ -34,5 +36,9 @@
"pino-pretty": "^11.2.1", "pino-pretty": "^11.2.1",
"typescript": ">=3.0.0" "typescript": ">=3.0.0"
}, },
"packageManager": "yarn@4.1.1" "packageManager": "yarn@4.4.0",
"repository": {
"type": "git",
"url": "git+https://git.computernewb.com/computernewb/superqemu.git"
}
} }

52
src/DefaultProcess.ts Normal file
View file

@ -0,0 +1,52 @@
// Default process implementation.
// This uses execa like the previous code, but conforms to our abstration :)
import EventEmitter from 'events';
import { IProcess, IProcessLauncher, ProcessLaunchOptions } from './ProcessInterface';
import { execaCommand } from 'execa';
import { Readable, Writable } from 'stream';
class DefaultProcess extends EventEmitter implements IProcess {
private process;
stdin: Writable | null = null;
stdout: Readable | null = null;
stderr: Readable | null = null;
constructor(command: string, opts?: ProcessLaunchOptions) {
super();
this.process = execaCommand(command, opts);
this.stdin = this.process.stdin;
this.stdout = this.process.stdout;
this.stderr = this.process.stderr;
let self = this;
this.process.on('spawn', () => {
self.emit('spawn');
});
this.process.on('exit', (code) => {
self.emit('exit', code);
});
}
kill(signal?: number | NodeJS.Signals): boolean {
return this.process.kill(signal);
}
dispose(): void {
this.stdin = null;
this.stdout = null;
this.stderr = null;
this.process.removeAllListeners();
this.removeAllListeners();
}
}
export class DefaultProcessLauncher implements IProcessLauncher {
launch(command: string, opts?: ProcessLaunchOptions | undefined): IProcess {
return new DefaultProcess(command, opts);
}
}

37
src/ProcessInterface.ts Normal file
View file

@ -0,0 +1,37 @@
import {type Stream, EventEmitter, Readable, Writable} from 'node:stream';
export type StdioOption =
| 'pipe'
| 'overlapped'
| 'ipc'
| 'ignore'
| 'inherit'
| Stream
| number
| undefined;
// subset of options. FIXME: Add more!!!
export interface ProcessLaunchOptions {
stdin?: StdioOption,
stdout?: StdioOption,
stderr?: StdioOption
}
export interface IProcess extends EventEmitter {
stdin: Writable | null;
stdout: Readable | null;
stderr: Readable | null;
// Escape hatch; only use this if you have no choice
//native() : any;
kill(signal?: number | NodeJS.Signals): boolean;
dispose(): void;
}
// Launches a processs.
export interface IProcessLauncher {
launch(command: string, opts?: ProcessLaunchOptions) : IProcess;
}

View file

@ -5,6 +5,8 @@ import { unlink } from 'node:fs/promises';
import pino from 'pino'; import pino from 'pino';
import { Readable, Writable } from 'stream'; import { Readable, Writable } from 'stream';
import { IProcess, IProcessLauncher } from './ProcessInterface.js';
import { DefaultProcessLauncher } from './DefaultProcess.js';
export enum VMState { export enum VMState {
Stopped, Stopped,
@ -13,6 +15,7 @@ export enum VMState {
Stopping Stopping
} }
/// VM definition.
export type QemuVmDefinition = { export type QemuVmDefinition = {
id: string; id: string;
command: string; command: string;
@ -22,6 +25,7 @@ export type QemuVmDefinition = {
vncPort: number | undefined; vncPort: number | undefined;
}; };
/// Display information.
export interface QemuVMDisplayInfo { export interface QemuVMDisplayInfo {
type: 'vnc-uds' | 'vnc-tcp'; type: 'vnc-uds' | 'vnc-tcp';
// 'vnc-uds' // 'vnc-uds'
@ -66,7 +70,8 @@ export class QemuVM extends EventEmitter {
// QMP stuff. // QMP stuff.
private qmpInstance: QmpClient = new QmpClient(); private qmpInstance: QmpClient = new QmpClient();
private qemuProcess: ExecaChildProcess | null = null; private qemuProcess: IProcess | null = null;
private qemuLauncher: IProcessLauncher;
private displayInfo: QemuVMDisplayInfo | null = null; private displayInfo: QemuVMDisplayInfo | null = null;
private definition: QemuVmDefinition; private definition: QemuVmDefinition;
@ -74,13 +79,22 @@ export class QemuVM extends EventEmitter {
private logger: pino.Logger; private logger: pino.Logger;
constructor(def: QemuVmDefinition) { constructor(def: QemuVmDefinition, processLauncher?: IProcessLauncher) {
super(); super();
this.definition = def; this.definition = def;
this.logger = pino({ this.logger = pino({
name: `SuperQEMU.QemuVM/${this.definition.id}` 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; let self = this;
// Handle the STOP event sent when using -no-shutdown // Handle the STOP event sent when using -no-shutdown
@ -94,7 +108,6 @@ export class QemuVM extends EventEmitter {
this.qmpInstance.on('connected', async () => { this.qmpInstance.on('connected', async () => {
self.logger.info('QMP ready'); self.logger.info('QMP ready');
self.SetState(VMState.Started); self.SetState(VMState.Started);
}); });
} }
@ -131,7 +144,6 @@ export class QemuVM extends EventEmitter {
}; };
} }
this.definition.command = cmd; this.definition.command = cmd;
this.addedAdditionalArguments = true; this.addedAdditionalArguments = true;
} }
@ -147,19 +159,42 @@ export class QemuVM extends EventEmitter {
await this.MonitorCommand('system_reset'); await this.MonitorCommand('system_reset');
} }
async Stop() { async Stop(): Promise<void> {
this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM'); 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. // Indicate we're stopping, so we don't erroneously start trying to restart everything we're going to tear down.
this.SetState(VMState.Stopping); this.SetState(VMState.Stopping);
// Stop the QEMU process, which will bring down everything else. // Stop the QEMU process, which will bring down everything else.
await this.StopQemu(); 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() { async Reset(): Promise<void> {
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM'); this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
await this.StopQemu(); 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> { async QmpCommand(command: string, args: any | null): Promise<any> {
@ -232,7 +267,7 @@ export class QemuVM extends EventEmitter {
this.logger.info(`Starting QEMU with command \"${split}\"`); this.logger.info(`Starting QEMU with command \"${split}\"`);
// Start QEMU // Start QEMU
this.qemuProcess = execaCommand(split, { this.qemuProcess = this.qemuLauncher.launch(split, {
stdin: 'pipe', stdin: 'pipe',
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe' stderr: 'pipe'
@ -250,6 +285,11 @@ export class QemuVM extends EventEmitter {
this.qemuProcess.on('exit', async (code) => { this.qemuProcess.on('exit', async (code) => {
self.logger.info('QEMU process exited'); 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.reset();
self.qmpInstance.setWriter(null); self.qmpInstance.setWriter(null);
@ -268,10 +308,12 @@ export class QemuVM extends EventEmitter {
// Note that we've already tore down everything upon entry to this event handler; therefore // Note that we've already tore down everything upon entry to this event handler; therefore
// we can simply set the state and move on. // we can simply set the state and move on.
this.SetState(VMState.Stopped); this.SetState(VMState.Stopped);
self.qemuProcess = null;
} }
} else { } else {
// Indicate we have stopped. // Indicate we have stopped.
this.SetState(VMState.Stopped); this.SetState(VMState.Stopped);
self.qemuProcess = null;
} }
}); });
} }
@ -279,7 +321,6 @@ export class QemuVM extends EventEmitter {
private async StopQemu() { private async StopQemu() {
if (this.qemuProcess) { if (this.qemuProcess) {
this.qemuProcess?.kill('SIGTERM'); this.qemuProcess?.kill('SIGTERM');
this.qemuProcess = null;
} }
} }

View file

@ -1,2 +1,3 @@
export * from './QemuVM.js'; export * from './QemuVM.js';
export * from './QmpClient.js'; export * from './QmpClient.js';
export * from './ProcessInterface.js';