From b8a06077a4c72aa4780773431a8a60afa1224269 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 6 Jun 2026 22:38:55 -0700 Subject: [PATCH] fix(sbx): make /sbx save work on a live multipass sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multipass can only snapshot a powered-off instance, so `/sbx save` on a running multipass sandbox previously just surfaced multipass's "instance must be stopped" error — making the snapshot that `/sbx load` needs impossible to create through the UI. Now multipass save powers the instance down before snapshotting, and the handler tears the shared session down first (same as `/sbx stop`, but WITHOUT purging so the instance — and thus the snapshot — survives for a later `/sbx load`). Docker is unchanged: `docker commit` captures a live container, so its save stays non-disruptive. Co-Authored-By: Claude Opus 4.6 --- hh/src/app.rs | 14 ++++++++++++++ hh/src/sbx.rs | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/hh/src/app.rs b/hh/src/app.rs index 1f32286..2070994 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -1673,6 +1673,20 @@ fn handle_command( "saving sandbox state as '{label}'{}…", if local { " (+ local copy)" } else { "" } )); + // Docker commits a *live* container, so its save is non- + // disruptive. Multipass can only snapshot a powered-off + // instance, so saving it necessarily stops the shared shell — + // tear the live session down (same as `/sbx stop`, but WITHOUT + // purging: the instance must survive so the snapshot does too). + if be == sbx::Backend::Multipass { + if let Some(mut sb) = broker.take() { + sb.stop(); + } + broker_meta.take(); + *announced_dims = None; + send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"})); + app.sys("multipass must power off to snapshot — stopping the shared shell, then saving (reload it with `/sbx load`)"); + } let (tx, lbl) = (app_tx.clone(), label.clone()); tokio::spawn(async move { let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label, local)).await; diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index e2cedc5..52c822e 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -589,6 +589,16 @@ pub fn save_state(backend: Backend, name: &str, label: &str, local: bool) -> Res Ok(format!("image {tag}")) } Backend::Multipass => { + // Multipass only snapshots a *stopped* instance (unlike docker, which + // commits a live container). Power it off first — the caller has + // already torn down the shared shell, so this is the save *and* stop. + // The instance stays registered (we don't purge), so `/sbx load` can + // restore the snapshot later. + let _ = Command::new("multipass") + .args(["stop", name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); let out = Command::new("multipass") .args(["snapshot", name, "--name", label]) .output()