diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-13 11:22:40 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-13 11:22:40 +0200 |
| commit | 87cd101eb3c55d0ad7e8f2142fcce90865da7d39 (patch) | |
| tree | a2bc25c6af717d13caadfbef008ab89b00770190 | |
| parent | f84176cf1c866da4d263d278774932acb81b4b8d (diff) | |
| download | quickshell-87cd101eb3c55d0ad7e8f2142fcce90865da7d39.tar.gz quickshell-87cd101eb3c55d0ad7e8f2142fcce90865da7d39.zip | |
feat(vm-manager): offer to discard a saved state that will not restore
A VM saved rather than shut down restores its memory image on the next
start. When that image cannot be restored the start fails every time with
"unable to execute QEMU command 'migrate-incoming'", and the panel showed
an ordinary "shut off" with a Start button that could never work.
Detection is virsh dominfo grepped for "Managed save: yes", polled per VM
on every list refresh the way the agent rows are. domstats does not report
it: the shut-off reason reads "failed", from the failed start, not from
the save. virsh list --all --managed-save does print "saved" in the state
column, but not together with --name, which is the form the panel lists
with.
The button appears only when a saved image exists, since managedsave-remove
fails without one and would be noise on every other VM. It takes a
confirmation click but not a typed name: it deletes the memory image and
leaves the disk alone, so the cost of a misclick is a cold boot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo1FG4qTr1inavmhqhfobe
| -rw-r--r-- | AGENTS.md | 9 | ||||
| -rw-r--r-- | vm-manager/README.md | 12 | ||||
| -rw-r--r-- | vm-manager/Virsh.qml | 45 | ||||
| -rw-r--r-- | vm-manager/VmPanel.qml | 14 |
4 files changed, 75 insertions, 5 deletions
@@ -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 ""; } |
