aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md9
-rw-r--r--vm-manager/README.md12
-rw-r--r--vm-manager/Virsh.qml45
-rw-r--r--vm-manager/VmPanel.qml14
4 files changed, 75 insertions, 5 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6a58d76..ee37cf7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -103,6 +103,15 @@ changing that component. The ones that generalise:
`block.allocation` is qcow2 growth on the host, not usage inside the guest.
The real numbers come from qemu-guest-agent, and the panel shows a dash
rather than substituting the host-side ones.
+- **A managed save makes `virsh start` a restore, and nothing in `domstats`
+ says so.** A shut-off VM carrying a saved memory image fails to start with
+ `unable to execute QEMU command 'migrate-incoming'` every time, and the
+ state reads a plain `shut off`. The shut-off *reason* is no help either: it
+ reads `failed`, from the failed start, not from the save. `virsh list
+ --all --managed-save` prints `saved` in the state column but not with
+ `--name`, which is the form the panel lists with, so detection is `virsh
+ dominfo <vm>` grepped for `Managed save: yes`, polled per VM the way the
+ agent rows are. It measured 6ms.
- **Hyprland 0.56.2 evaluates dispatch arguments as Lua.** `dispatch
focuswindow address:0x...` is a syntax error rather than a command, and it
fails silently unless stderr is read. The working form is `dispatch
diff --git a/vm-manager/README.md b/vm-manager/README.md
index 8d817f9..bcf12ac 100644
--- a/vm-manager/README.md
+++ b/vm-manager/README.md
@@ -85,6 +85,18 @@ in a delete confirmation gives it up.
## Destructive actions
+`Discard saved state` appears on a shut-off VM only when one actually exists,
+and runs `virsh managedsave-remove`. A VM that was saved rather than shut down
+restores that memory image on the next `start`, and when the image cannot be
+restored the start fails every time with a QEMU `migrate-incoming` error while
+the panel shows an ordinary `shut off`. Discarding it deletes the memory image
+and nothing else, so the next start is a cold boot and the disk is untouched.
+That is why it takes a confirmation but not a typed name.
+
+Detection is `virsh dominfo`, grepped for `Managed save: yes`, once per VM on
+every list refresh. `domstats` does not carry it and `virsh list --name` drops
+the column that would.
+
`Reset`, `Force stop`, snapshot `Revert` and snapshot `Delete` each take one
confirmation click. `Delete VM` requires the machine's name to be typed,
because it runs `virsh undefine --remove-all-storage`, which erases the disk
diff --git a/vm-manager/Virsh.qml b/vm-manager/Virsh.qml
index 309781c..14bd368 100644
--- a/vm-manager/Virsh.qml
+++ b/vm-manager/Virsh.qml
@@ -21,7 +21,7 @@ import QtQuick
Singleton {
id: root
- // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent }
+ // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent, saved }
property var vms: ({})
property var names: []
property var snapshots: ({}) // name -> [{ name, created, state, current }]
@@ -33,7 +33,8 @@ Singleton {
function _vm(name) {
return vms[name] ?? { state: "unknown", cpu: -1, memUsed: -1, memTotal: -1,
- fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false };
+ fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false,
+ saved: false };
}
function _set(name, fields) {
@@ -52,6 +53,7 @@ Singleton {
const found = text.trim().split("\n").map(s => s.trim()).filter(s => s.length);
root.names = found;
for (const n of found) if (!(n in root.vms)) root._set(n, {});
+ root._pollSaved();
root.refresh();
}
}
@@ -191,6 +193,44 @@ Singleton {
onExited: code => { if (code !== 0) { root._set(vm, { ip: "" }); root._nextAgent(); } }
}
+ // --- managed save ----------------------------------------------------
+ //
+ // A shut-off VM can still carry a saved memory image, and `virsh start`
+ // then restores it rather than booting. When that image cannot be
+ // restored the start fails every time with a QEMU migrate-incoming
+ // error, and nothing in the panel said why. `domstats` does not report
+ // it: the shut-off reason reads "failed", from the failed start, not
+ // from the save. `dominfo` is the only cheap source, so it is polled per
+ // VM the way the agent rows are.
+
+ property var _savedQueue: []
+
+ function _pollSaved() {
+ _savedQueue = names.slice();
+ _nextSaved();
+ }
+
+ function _nextSaved() {
+ if (_savedQueue.length === 0) return;
+ const vm = _savedQueue[0];
+ _savedQueue = _savedQueue.slice(1);
+ savedProc.vm = vm;
+ savedProc.command = ["virsh", "dominfo", vm];
+ savedProc.running = true;
+ }
+
+ Process {
+ id: savedProc
+ property string vm: ""
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root._set(savedProc.vm, { saved: /^Managed save:\s+yes$/m.test(text) });
+ root._nextSaved();
+ }
+ }
+ onExited: code => { if (code !== 0) { root._set(vm, { saved: false }); root._nextSaved(); } }
+ }
+
// --- snapshots -------------------------------------------------------
Process {
@@ -227,6 +267,7 @@ Singleton {
destroy: v => ["virsh", "destroy", v],
suspend: v => ["virsh", "suspend", v],
resume: v => ["virsh", "resume", v],
+ discardsave: v => ["virsh", "managedsave-remove", v],
})
function act(vm, action) {
diff --git a/vm-manager/VmPanel.qml b/vm-manager/VmPanel.qml
index cf224d6..5960a1a 100644
--- a/vm-manager/VmPanel.qml
+++ b/vm-manager/VmPanel.qml
@@ -62,16 +62,21 @@ Scope {
// Which verbs make sense in the current state, mirroring the states the
// old rofi script switched on.
- function actionsFor(s) {
+ function actionsFor(s, saved) {
if (s === "running")
return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"],
["reset", "Reset"], ["destroy", "Force stop"]];
if (s === "paused" || s === "suspended")
return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]];
+ // Only worth offering when a saved image actually exists: without one
+ // managedsave-remove fails, and the button would be noise on every
+ // other VM.
+ if (saved)
+ return [["start", "Start"], ["discardsave", "Discard saved state"]];
return [["start", "Start"]];
}
- function isDestructive(a) { return a === "reset" || a === "destroy"; }
+ function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; }
function run(vm, action) {
if (isDestructive(action)) root.confirming = { kind: action, vm: vm, snap: "" };
@@ -265,6 +270,7 @@ Scope {
sourceComponent: detail
property string vmName: modelData
property string vmState: vm.state ?? ""
+ property bool vmSaved: vm.saved ?? false
}
}
}
@@ -282,6 +288,7 @@ Scope {
readonly property string vmName: parent.vmName
readonly property string vmState: parent.vmState
+ readonly property bool vmSaved: parent.vmSaved
Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) }
@@ -299,7 +306,7 @@ Scope {
spacing: 8
Repeater {
- model: root.actionsFor(vmState)
+ model: root.actionsFor(vmState, vmSaved)
Button {
required property var modelData
text: modelData[1]
@@ -391,6 +398,7 @@ Scope {
if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`;
if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`;
if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`;
+ if (c.kind === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`;
if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`;
return "";
}