From 54b058c04554f69631a9877f50b38f54b5390be7 Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sun, 10 May 2026 16:03:39 +0000 Subject: [PATCH] feat: enable support for dockurr/windows memory ballooning feature Adds experimental support for memory ballooning feature provided by dockurr/windows image, with support for stats and upgrade of existing containers created with previous dockurr/windows versions. --- guest_server/main.go | 54 ++++++++++++ src/renderer/lib/qmp.ts | 157 +++++++++++++++++++++++----------- src/renderer/lib/winboat.ts | 76 +++++++++++++++- src/renderer/views/Config.vue | 26 ++++++ src/renderer/views/Home.vue | 14 ++- src/types.ts | 7 ++ 6 files changed, 277 insertions(+), 57 deletions(-) diff --git a/guest_server/main.go b/guest_server/main.go index 26b41201..e672d840 100644 --- a/guest_server/main.go +++ b/guest_server/main.go @@ -127,6 +127,58 @@ func getMetrics(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(metrics) } +func getBalloonStatus(w http.ResponseWriter, r *http.Request) { + cmd := exec.Command("C:\\Windows\\Drivers\\Balloon\\blnsvr.exe", "status") + output, err := cmd.CombinedOutput() + + response := map[string]string{} + + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + switch exitErr.ExitCode() { + case 1060: + response["status"] = "not-installed" + response["details"] = string(output) + case 1, 1062: + response["status"] = "stopped" + response["details"] = string(output) + default: + response["status"] = "error" + response["details"] = string(output) + } + } else { + response["status"] = "error" + response["details"] = err.Error() + } + } else { + response["status"] = "running" + response["details"] = string(output) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +func installBalloon(w http.ResponseWriter, r *http.Request) { + cmd := exec.Command("C:\\Windows\\Drivers\\Balloon\\blnsvr.exe", "-i") + output, err := cmd.CombinedOutput() + + response := map[string]string{} + + if err != nil { + response["result"] = "error" + response["details"] = string(output) + } else { + response["result"] = "success" + response["details"] = string(output) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + func getRdpConnectedStatus(w http.ResponseWriter, r *http.Request) { // Check for RDP Status via quser.exe cmd := exec.Command("quser.exe") @@ -333,6 +385,8 @@ func main() { r.HandleFunc("/health", getHealth).Methods("GET") r.HandleFunc("/version", getVersion).Methods("GET") r.HandleFunc("/metrics", getMetrics).Methods("GET") + r.HandleFunc("/balloon/status", getBalloonStatus).Methods("GET") + r.HandleFunc("/balloon/install", installBalloon).Methods("POST") r.HandleFunc("/rdp/status", getRdpConnectedStatus).Methods("GET") r.HandleFunc("/update", applyUpdate).Methods("POST") r.HandleFunc("/get-icon", getIcon).Methods("POST") diff --git a/src/renderer/lib/qmp.ts b/src/renderer/lib/qmp.ts index d1027e86..8f083fdf 100644 --- a/src/renderer/lib/qmp.ts +++ b/src/renderer/lib/qmp.ts @@ -2,6 +2,7 @@ import { WINBOAT_DIR } from "./constants"; import { createLogger } from "../utils/log"; const path: typeof import("path") = require("node:path"); import { type Socket } from "net"; +import { EventEmitter } from "events"; import { assert } from "@vueuse/core"; const { createConnection }: typeof import("net") = require("node:net"); @@ -50,6 +51,10 @@ type QMPBlockInfo = { inserted?: object; }; +type QMPQueryBalloon = { + actual: number; +}; + type QMPError = { error: object; }; @@ -57,7 +62,7 @@ type QMPError = { type QMPReturn = T extends never ? never : { return: T } | QMPError; type QMPCommandWithArgs = "human-monitor-command" | "device_add" | "device_del" | "device-list-properties"; -type QMPCommandNoArgs = "qmp_capabilities" | "query-commands" | "query-status" | "query-block"; +type QMPCommandNoArgs = "qmp_capabilities" | "query-commands" | "query-status" | "query-block" | "query-balloon"; type QMPCommand = QMPCommandWithArgs | QMPCommandNoArgs; type QMPArgumentProps = { @@ -106,18 +111,86 @@ export type QMPResponse = QMPReturn< ? QMPObjectPropertyInfo[] : T extends "query-block" ? QMPBlockInfo[] - : never + : T extends "query-balloon" + ? QMPQueryBalloon[] + : never >; -export class QMPManager { +type QMPEvents = { + message: [message: any]; +}; + +export class QMPManager extends EventEmitter { private static readonly IS_ALIVE_TIMEOUT = 2000; - qmpSocket: Socket; + private static readonly DEFAULT_COMMAND_TIMEOUT = 10000; + private qmpSocket!: Socket; + private buffer: Buffer = Buffer.alloc(0); + private commandId: number = 0; + private pendingCommands: Map void; reject: (error: Error) => void; timeout: NodeJS.Timeout }> = new Map(); /** * Please use {@link QMPManager.createConnection} instead. */ - constructor(socket: Socket) { - this.qmpSocket = socket; + private constructor(private host: string, private port: number) { + super(); + } + + private async connect(): Promise { + return new Promise((resolve, reject) => { + this.once("message", (message) => { + if ("QMP" in message) { + resolve(); + } else { + reject(new Error(`Invalid QMP greeting: ${JSON.stringify(message)}`)); + } + }); + + this.qmpSocket = createConnection({ host: this.host, port: this.port }, () => { + this.qmpSocket.once("error", reject); + this.qmpSocket.on("data", this.handleData); + }); + }); + } + + public disconnect() { + this.qmpSocket.off("data", this.handleData); + this.buffer = Buffer.alloc(0); + this.qmpSocket.destroy(); + } + + private handleData = (data: Buffer) => { + this.buffer = Buffer.concat([this.buffer, data]); + let separatorIndex: number; + while ((separatorIndex = this.buffer.indexOf("\r\n")) !== -1) { + const messageBuffer = this.buffer.subarray(0, separatorIndex); + this.buffer = this.buffer.subarray(separatorIndex + 2); + if (messageBuffer.length > 0) { + try { + const parsed = JSON.parse(messageBuffer.toString()); + this.handleMessage(parsed); + } catch (e) { + logger.error("Failed to parse QMP message:", e); + logger.error("Message:", messageBuffer.toString()); + } + } + } + } + + private handleMessage(message: any) { + if ("event" in message) { + // Currently ignored. + return; + } else if ("id" in message) { + const pending = this.pendingCommands.get(message.id); + if (pending) { + clearTimeout(pending.timeout); + this.pendingCommands.delete(message.id); + pending.resolve(message); + } + } else { + // Generic message (usually the greeting). + this.emit("message", message); + } } /** @@ -129,76 +202,58 @@ export class QMPManager { * @param port - The port of the qmp connection (e.g. 6969, 420) * */ - static async createConnection(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const socket = createConnection({ host, port }, () => { - socket.once("error", reject); - socket.once("data", data => { - try { - const response = JSON.parse(data.toString()); - - if ("QMP" in response) { - return resolve(new QMPManager(socket)); - } - - reject(new Error(`Invalid QMP response: ${data.toString()}`)); - } catch (e) { - logger.error(e); - logger.error(`QMP request 'data.toString()': ${data.toString()}`); - reject(e); - } - }); - }); - }); + static async createConnection(host: string, port: number) { + const manager = new QMPManager(host, port); + await manager.connect(); + return manager; } /** * Executes the QMP command specified by `command`. * - * Optionally, you can specify an argument for given command if it requires one. + * Optionally, you can specify an argument for given command if it requires one and a timeout in ms. * * @param command * */ - async executeCommand(command: C): Promise>; + async executeCommand(command: C, timeout?: number): Promise>; async executeCommand( command: C, qmpArgument: QMPCommandExpectedArgument, + timeout?: number, ): Promise>; async executeCommand( command: C, - qmpArgument?: QMPCommandExpectedArgument, + qmpArgument_or_timeout?: QMPCommandExpectedArgument | number, + timeout?: number, ): Promise> { + const id = ++this.commandId; + const actualTimeout = typeof qmpArgument_or_timeout === "number" ? qmpArgument_or_timeout : (timeout ?? QMPManager.DEFAULT_COMMAND_TIMEOUT); + const actualArgument = typeof qmpArgument_or_timeout === "object" ? qmpArgument_or_timeout : undefined; const message = { execute: command, - ...(qmpArgument && { arguments: qmpArgument }), + id: id, + ...(actualArgument && { arguments: actualArgument }), }; return new Promise>((resolve, reject) => { - this.qmpSocket.write(JSON.stringify(message), err => { + const timeoutHandle = setTimeout(() => { + this.pendingCommands.delete(id); + reject(new Error(`QMP command '${command}' timed out after ${actualTimeout}ms`)); + }, actualTimeout); + + this.pendingCommands.set(id, { resolve, reject, timeout: timeoutHandle }); + + this.qmpSocket.write(JSON.stringify(message) + "\r\n", err => { if (err) { logger.error(err); + const pending = this.pendingCommands.get(id); + if (pending) { + clearTimeout(pending.timeout); + this.pendingCommands.delete(id); + } reject(err); } - - // This callback processes data received from the QMP socket - const receiveData = (data: Buffer) => { - try { - const parsedData = JSON.parse(data.toString()); - if ("event" in parsedData) return; // In case we get notified of an event (for example NETDEV_STREAM_CONNECTED), we ignore it - - // We remove our callback from the data event when we get the response - this.qmpSocket.off("data", receiveData); - resolve(JSON.parse(data.toString())); - } catch (e) { - logger.error(e); - logger.error(`QMP request 'data.toString()': ${data.toString()}`); - reject(e); - } - }; - - // We can't do 'qmpSocket.once', since we may get an event notice in between sending the command and receiving the response. - this.qmpSocket.on("data", receiveData); }); }); } diff --git a/src/renderer/lib/winboat.ts b/src/renderer/lib/winboat.ts index d6d52f19..1ad447ca 100644 --- a/src/renderer/lib/winboat.ts +++ b/src/renderer/lib/winboat.ts @@ -5,6 +5,7 @@ import type { CustomAppCallbacks, GuestServerUpdateResponse, GuestServerVersion, + MemoryStats, Metrics, WinApp, } from "../../types"; @@ -250,6 +251,12 @@ export class Winboat { percentage: 0, }, }); + memoryStats: Ref = ref({ + used: 0, + total: 0, + totalAvailable: 0, + percentage: 0, + }); readonly #wbConfig: WinboatConfig | null = null; appMgr: AppManager | null = null; qmpMgr: QMPManager | null = null; @@ -308,6 +315,11 @@ export class Winboat { if (this.isOnline.value) { await this.checkVersionAndUpdateGuestServer(); + + // If experimental features are enable, ensure that the ballooning service is installed. + if (this.#wbConfig?.config.experimentalFeatures) { + await this.checkBallooningService(); + } } } }, HEALTH_WAIT_MS); @@ -322,7 +334,23 @@ export class Winboat { this.#metricsInverval = setInterval(async () => { // If the guest is offline or updating, don't bother checking metrics if (!this.isOnline.value || this.isUpdatingGuestServer.value) return; - this.metrics.value = await this.getMetrics(); + + const metrics = await this.getMetrics(); + + // Get the current balloon AFTER guest metrics, to reduce out-of-sync data. + const actualBalloon = await this.getActualBalloon(); + + const balloonSize = !isNaN(actualBalloon) ? Math.max(metrics.ram.total - actualBalloon, 0) : 0; + + this.metrics.value = metrics; + this.memoryStats.value = { + used: Math.max(metrics.ram.used - balloonSize, 0), + total: metrics.ram.total, + totalAvailable: metrics.ram.total - balloonSize, + percentage: !isNaN(actualBalloon) && actualBalloon > 0 + ? ((metrics.ram.used - balloonSize) / actualBalloon) * 100 + : metrics.ram.percentage, + }; }, METRICS_WAIT_MS); // *** RDP Connection Status Interval *** @@ -395,7 +423,7 @@ export class Winboat { // Side effect: We must destroy the QMP Manager try { if (this.qmpMgr && (await this.qmpMgr.isAlive())) { - this.qmpMgr.qmpSocket.destroy(); + this.qmpMgr.disconnect(); } this.qmpMgr = null; logger.info("[destroyAPIIntervals] QMP Manager destroyed because container is no longer running"); @@ -421,6 +449,24 @@ export class Winboat { const metrics = (await res.json()) as Metrics; return metrics; } + + async getActualBalloon() { + // If experimental feature are enabled fetch balloon size from QMP (if available) + if (this.#wbConfig?.config.experimentalFeatures) { + let response = null; + try { + response = await this.qmpMgr!.executeCommand("query-balloon"); + assert("result" in response); + // @ts-ignore property "result" already exists due to assert + return Math.round(response!.return.actual / (1024**2)); + } catch (e) { + logger.error("There was an error checking for balloon"); + logger.error(e); + logger.error(`QMP response: ${JSON.stringify(response)}`); + } + } + return NaN; + } async getRDPConnectedStatus() { const res = await nodeFetch(`${this.apiUrl}/rdp/status`, { signal: AbortSignal.timeout(FETCH_TIMEOUT) }); @@ -796,6 +842,32 @@ export class Winboat { this.isUpdatingGuestServer.value = false; } + async checkBallooningService() { + // Check if ballooning service is installed in the Windows vm (automatically installed by newer + // version of dockurr/windows image), and install it if missing. + try { + const statusRes = await nodeFetch(`${this.apiUrl}/balloon/status`); + const status = (await statusRes.json()) as { status: string; details: string }; + + if (status.status === "not-installed") { + logger.info("Balloon service not installed, installing..."); + const installRes = await nodeFetch(`${this.apiUrl}/balloon/install`, { + method: "POST" + }); + const installResult = (await installRes.json()) as { result: string; details: string }; + + if (installResult.result === "success") { + logger.info("Balloon service installed successfully"); + } else { + logger.error(`Failed to install balloon service: ${installResult.details}`); + } + } + } catch (e) { + logger.error("Failed to check/install balloon service"); + logger.error(e); + } + } + /** * Whether or not the Winboat singleton has a QMP interval active */ diff --git a/src/renderer/views/Config.vue b/src/renderer/views/Config.vue index 8ffb1e84..bf6430f4 100644 --- a/src/renderer/views/Config.vue +++ b/src/renderer/views/Config.vue @@ -15,6 +15,15 @@ v-model:value="ramGB" /> + + v.includes("/shared")); if (sharedVolume) { @@ -569,6 +585,12 @@ async function saveCompose() { compose.value!.services.windows.environment.RAM_SIZE = `${ramGB.value}G`; compose.value!.services.windows.environment.CPU_CORES = `${numCores.value}`; + if (memoryBallooning.value) { + compose.value!.services.windows.environment["BALLOONING"] = "Y"; + } else if ("BALLOONING" in compose.value!.services.windows.environment) { + delete compose.value!.services.windows.environment["BALLOONING"]; + } + // Remove any existing shared volume const existingSharedVolume = compose.value!.services.windows.volumes.find(v => v.includes("/shared")); if (existingSharedVolume) { @@ -722,6 +744,7 @@ const saveButtonDisabled = computed(() => { const hasResourceChanges = origNumCores.value !== numCores.value || origRamGB.value !== ramGB.value || + origMemoryBallooning.value !== memoryBallooning.value || shareFolder.value !== origShareFolder.value || sharedFolderPath.value !== origSharedFolderPath.value || (!Number.isNaN(freerdpPort.value) && freerdpPort.value !== origFreerdpPort.value) || @@ -775,9 +798,12 @@ function removeDevice(ptDevice: PTSerializableDeviceInfo): void { async function toggleExperimentalFeatures() { // Remove all passthrough USB devices if we're disabling experimental features // since USB passthrough is an experimental feature + // Disable also memory ballooning. if (!wbConfig.config.experimentalFeatures) { await usbManager.removeAllPassthroughDevicesAndConfig(); + memoryBallooning.value == false; + // Create the QMP interval if experimental features are enabled // This would get created by default since we're changing the compose and re-deploying, // but a scenario could also occur where the user is re-enabling experimental features diff --git a/src/renderer/views/Home.vue b/src/renderer/views/Home.vue index b1b21155..80b4bed3 100644 --- a/src/renderer/views/Home.vue +++ b/src/renderer/views/Home.vue @@ -142,7 +142,7 @@ class="translate-y-2" type="radialBar" :options="chartOptions" - :series="[winboat.metrics.value.ram.percentage]" + :series="[winboat.memoryStats.value.percentage]" :width="120" :height="120" /> @@ -152,10 +152,10 @@

RAM

- {{ Math.round(winboat.metrics.value.ram.total / 1024).toFixed(2) }} GB Total RAM + {{ (winboat.memoryStats.value.totalAvailable / 1024).toFixed(2) }}/{{ Math.round(winboat.memoryStats.value.total / 1024).toFixed(2) }} GB Total

- {{ (winboat.metrics.value.ram.used / 1024).toFixed(2) }} GB Used RAM + {{ (winboat.memoryStats.value.used / 1024).toFixed(2) }} GB Used

@@ -186,7 +186,7 @@