From 21ed443e04f3a6a21d64ecb32c76f689746efac9 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 23 Jul 2026 09:56:01 +1000 Subject: [PATCH] fix(desktop): Share toggle reflects serve mode, not slot occupancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consuming a peer's compute (selecting Buzz compute as an agent's provider) spins up a client-mode node in the single mesh runtime slot, which reports state:"running". The Share toggle keyed off state alone, so it lit up during a consume session — and toggling it off ran the generic mesh_stop_node, tearing down that session. Toggle now reads mode: serve = sharing (on), client = consuming (off). A serve node that also routes a peer's model stays on. mesh_stop_node refuses to tear down a client node. A failed serve node still occupies the single slot, so it reads as sharing (switch stays on and turn-off-able to clear/retry) and blocks a fresh start; slotOccupied gates the model inputs. Consume copy states the fact rather than promising a stop action that doesn't exist. - deriveMeshShareToggle predicate (+ slotOccupied) + unit table - backend share_stop_should_teardown guard + serde contract test - e2e regression: consuming does not light the Share toggle --- desktop/src-tauri/src/commands/mesh_llm.rs | 76 +++++++++++++++- desktop/src-tauri/src/mesh_llm/mod.rs | 8 ++ .../mesh-compute/shareToggleState.test.mjs | 90 +++++++++++++++++++ .../features/mesh-compute/shareToggleState.ts | 64 +++++++++++++ .../ui/MeshComputeSettingsCard.tsx | 51 +++++++++-- desktop/src/testing/e2eBridge.ts | 18 ++++ desktop/tests/e2e/mesh-compute.spec.ts | 45 ++++++++++ 7 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/mesh-compute/shareToggleState.test.mjs create mode 100644 desktop/src/features/mesh-compute/shareToggleState.ts diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index ad55d9506..03f3ece17 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -45,6 +45,18 @@ fn load_mesh_sharing_config(app: &AppHandle) -> Result const RELAY_MESH_RUNTIME_NO_TARGET: &str = "Buzz shared compute requires a live serving member; start serving the selected model on a member, then try again"; +/// Whether the Share-compute "stop sharing" path (`mesh_stop_node`) should tear +/// down the runtime currently occupying the single slot. +/// +/// Serve nodes (this machine SHARING compute) are torn down. Client nodes (this +/// machine CONSUMING a peer's compute) share the same slot and MUST be left +/// running — stopping "Share compute" must never kill a consume session the +/// user didn't start from this switch. +#[cfg(feature = "mesh-llm")] +fn share_stop_should_teardown(mode: mesh_llm::MeshNodeMode) -> bool { + matches!(mode, mesh_llm::MeshNodeMode::Serve) +} + pub type CmdResult = Result; fn advance_mesh_status_cursor( @@ -411,8 +423,22 @@ pub async fn mesh_stop_node( app: AppHandle, state: State<'_, AppState>, ) -> CmdResult { - let runtime = state.mesh_llm_runtime.lock().await.take(); - if let Some(runtime) = runtime { + // The single runtime slot is shared by serve (this machine SHARING + // compute) and client (this machine CONSUMING a peer's compute) roles. + // Stopping "Share compute" must NEVER tear down a client node: inspect the + // role under the lock and, when it's a consume session, leave it running + // and return its live status unchanged. The frontend also guards this, but + // status can be stale between polls, so the backend is authoritative. + let taken = { + let mut guard = state.mesh_llm_runtime.lock().await; + if let Some(runtime) = guard.as_ref() { + if !share_stop_should_teardown(runtime.mode()) { + return runtime.status().await.map_err(|error| error.to_string()); + } + } + guard.take() + }; + if let Some(runtime) = taken { runtime.stop().await.map_err(|error| error.to_string())?; } save_mesh_sharing_config( @@ -546,6 +572,52 @@ mod tests { assert_eq!(pick_serve_target_for_model(targets, "model-missing"), None); } + #[test] + fn share_stop_tears_down_serve_but_not_client() { + // Stopping "Share compute" tears down a serve node (we were sharing) + // but must leave a client node alone (we are consuming a peer). This is + // the backend half of the toggle-on regression: a client node occupies + // the single slot and reports state:"running", and the stop path must + // not kill it. + assert!( + share_stop_should_teardown(mesh_llm::MeshNodeMode::Serve), + "serve node is our sharing runtime; stop must tear it down" + ); + assert!( + !share_stop_should_teardown(mesh_llm::MeshNodeMode::Client), + "client node is a consume session; stop must NOT tear it down" + ); + } + + #[test] + fn client_status_serializes_with_running_state_and_client_mode() { + // Contract pin for the TS mock (e2eBridge.ts) and the frontend + // predicate: a consuming node serializes as + // {"state":"running","mode":"client"}. If serde renaming drifts, the + // hand-written mock shape and `deriveMeshShareToggle` would silently + // stop matching the real IPC payload. + let status = mesh_llm::MeshNodeStatus { + state: mesh_llm::MeshNodeState::Running, + mode: Some(mesh_llm::MeshNodeMode::Client), + // `MeshHealth::ok()` is module-private; build via the public fields. + health: mesh_llm::MeshHealth { + status: mesh_llm::MeshHealthStatus::Ok, + reason: None, + }, + api_base_url: Some("http://127.0.0.1:9337/v1".to_string()), + console_url: None, + model_id: None, + model_name: None, + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + }; + let value = serde_json::to_value(&status).expect("serialize mesh status"); + assert_eq!(value["state"], serde_json::json!("running")); + assert_eq!(value["mode"], serde_json::json!("client")); + } + #[tokio::test] async fn cold_client_preflight_requires_explicit_target() { let state = build_app_state(); diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 4ec3adaab..9b01d185c 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -381,6 +381,14 @@ impl DesktopMeshRuntime { &self.start_request } + /// The role this runtime was started in. Serve = this machine is SHARING + /// compute; Client = this machine is CONSUMING a peer's compute. Both + /// occupy the single runtime slot, so callers that act only on the sharing + /// role (e.g. the Share-compute stop path) must check this first. + pub fn mode(&self) -> MeshNodeMode { + self.mode + } + pub async fn status(&self) -> anyhow::Result { let status = self.handle.status().await?; self.status_from_sdk(status) diff --git a/desktop/src/features/mesh-compute/shareToggleState.test.mjs b/desktop/src/features/mesh-compute/shareToggleState.test.mjs new file mode 100644 index 000000000..b409a9c3c --- /dev/null +++ b/desktop/src/features/mesh-compute/shareToggleState.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveMeshShareToggle } from "./shareToggleState.ts"; + +const status = (overrides = {}) => ({ + state: "off", + mode: null, + health: { status: "ok", reason: null }, + apiBaseUrl: null, + consoleUrl: null, + modelId: null, + modelName: null, + ...overrides, +}); + +test("serve-mode running/starting reads as sharing", () => { + for (const state of ["running", "starting"]) { + const model = deriveMeshShareToggle(status({ state, mode: "serve" })); + assert.equal(model.isSharing, true, `serve+${state} should be sharing`); + assert.equal(model.isConsuming, false); + assert.equal(model.slotOccupied, true); + } +}); + +test("client-mode running/starting is consuming, NOT sharing (regression)", () => { + // The core bug: consuming a peer's compute starts a client node in the same + // slot, which reports state:"running". The Share toggle must stay off. + for (const state of ["running", "starting"]) { + const model = deriveMeshShareToggle(status({ state, mode: "client" })); + assert.equal( + model.isSharing, + false, + `client+${state} must NOT light the Share toggle`, + ); + assert.equal(model.isConsuming, true); + assert.equal(model.slotOccupied, true); + } +}); + +test("a FAILED serve node still occupies the slot and stays turn-off-able", () => { + // Expert review: a failed runtime still holds the single slot, so a fresh + // start would throw "already running". It must read as sharing (so the + // switch stays on and can be turned OFF to clear/retry) and occupy the slot. + const model = deriveMeshShareToggle( + status({ state: "failed", mode: "serve" }), + ); + assert.equal( + model.isSharing, + true, + "failed serve node is still turn-off-able", + ); + assert.equal(model.slotOccupied, true); +}); + +test("a FAILED client node occupies the slot but is not sharing", () => { + const model = deriveMeshShareToggle( + status({ state: "failed", mode: "client" }), + ); + assert.equal(model.isSharing, false); + assert.equal(model.isConsuming, true); + assert.equal(model.slotOccupied, true); +}); + +test("off / stopping never occupy the slot or read as sharing/consuming", () => { + for (const state of ["off", "stopping"]) { + for (const mode of [null, "serve", "client"]) { + const model = deriveMeshShareToggle(status({ state, mode })); + assert.equal(model.isSharing, false, `${mode}+${state} not sharing`); + assert.equal(model.isConsuming, false, `${mode}+${state} not consuming`); + assert.equal(model.slotOccupied, false, `${mode}+${state} slot free`); + } + } +}); + +test("null status (not yet fetched) is neither sharing nor consuming", () => { + const model = deriveMeshShareToggle(null); + assert.equal(model.isSharing, false); + assert.equal(model.isConsuming, false); + assert.equal(model.slotOccupied, false); +}); + +test("running with a missing mode occupies the slot but is not sharing", () => { + // Defensive: a status that somehow lacks mode must not default to "on", but + // it DOES hold the slot (a fresh start would fail), so it stays occupied. + const model = deriveMeshShareToggle(status({ state: "running", mode: null })); + assert.equal(model.isSharing, false); + assert.equal(model.isConsuming, false); + assert.equal(model.slotOccupied, true); +}); diff --git a/desktop/src/features/mesh-compute/shareToggleState.ts b/desktop/src/features/mesh-compute/shareToggleState.ts new file mode 100644 index 000000000..e0a9b1fc4 --- /dev/null +++ b/desktop/src/features/mesh-compute/shareToggleState.ts @@ -0,0 +1,64 @@ +import type { MeshNodeStatus } from "@/shared/api/tauriMesh"; + +/** + * Derived Share-compute toggle model. + * + * The single mesh runtime slot is shared by BOTH roles: serve mode (this + * machine SHARING compute) and client mode (this machine CONSUMING a peer's + * compute). Both report `state: "running"`. The Share toggle must therefore + * key off `mode`, not `state` alone — otherwise consuming a peer's compute + * lights up the Share switch and (worse) clicking it tears down the unrelated + * client session. See `deriveMeshShareToggle`. + */ +export type MeshShareToggleModel = { + /** + * The Share switch is on: a serve-mode node occupies the slot. Stays true + * while it is starting or even if it later fails health (the runtime still + * occupies the slot, and the user must be able to turn it off to clear/retry + * — see `StatusLine` for the health sub-state). A serve node that also routes + * a peer's model is still sharing: routing is a capability, not a role. + */ + isSharing: boolean; + /** + * A client-mode runtime occupies the single slot (this machine is consuming + * a peer's compute). The Share switch must read off + disabled while true. + */ + isConsuming: boolean; + /** + * ANY runtime occupies the single slot (serve or client, healthy or failed). + * A fresh `mesh_start_node` fails with "already running" while true, so the + * switch must not offer a start — only a stop of an existing serve node. + */ + slotOccupied: boolean; +}; + +/** + * A runtime object occupies the slot once it is starting or running — and also + * when it has `failed` (it started, then errored; the runtime is still in the + * slot and blocks a fresh start). `off`/`stopping` do not occupy it. + */ +function occupiesSlot(status: MeshNodeStatus | null): boolean { + return ( + status?.state === "running" || + status?.state === "starting" || + status?.state === "failed" + ); +} + +/** + * Project a mesh node status into the Share toggle's on/consuming state. + * + * Pure and total (accepts `null` = status not yet fetched). This is the single + * source of truth for "is this machine sharing?" — the component and its + * regression tests both consume it so the `state`-only bug cannot come back. + */ +export function deriveMeshShareToggle( + status: MeshNodeStatus | null, +): MeshShareToggleModel { + const occupied = occupiesSlot(status); + return { + isSharing: occupied && status?.mode === "serve", + isConsuming: occupied && status?.mode === "client", + slotOccupied: occupied, + }; +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index 011ab408f..aef4351bd 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -29,6 +29,7 @@ import { useMeshDownloadProgress, } from "../hooks/useMeshDownloadProgress"; import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus"; +import { deriveMeshShareToggle } from "../shareToggleState"; const MODEL_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.model.v1"; const MAX_VRAM_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.max-vram-gb.v1"; @@ -130,8 +131,15 @@ export function MeshComputeSettingsCard() { } }, [status?.state, status?.modelId, modelInput]); - const isOn = status?.state === "running" || status?.state === "starting"; - const controlsDisabled = isOn || actionInFlight; + // The Share toggle reflects ONLY serve-mode occupancy. A client-mode runtime + // (this machine consuming a peer's compute) shares the single runtime slot + // and also reports state:"running" — but it must NOT light this switch, and + // toggling off must never tear down that unrelated consume session. + const { isSharing, isConsuming, slotOccupied } = + deriveMeshShareToggle(status); + // Any occupying runtime (serve or client, healthy or failed) locks the model + // inputs and blocks a fresh start — stop it before reconfiguring. + const controlsDisabled = slotOccupied || actionInFlight; const refClass = classifyModelRef(modelInput); const canStart = refClass.kind !== "unknown" && @@ -139,6 +147,13 @@ export function MeshComputeSettingsCard() { status?.state !== "starting"; async function handleToggle(next: boolean) { + // Never let the Share switch tear down a consume session. The switch is + // already disabled while consuming, but status can be stale between polls, + // so refuse a stop that isn't stopping OUR serve node as a belt-and-braces + // guard (the backend enforces this authoritatively too). + if (!next && !isSharing) { + return; + } setActionError(null); setPendingAction(next ? "start" : "stop"); setActionInFlight(true); @@ -202,12 +217,24 @@ export function MeshComputeSettingsCard() { > Share this machine - + @@ -488,9 +515,11 @@ function CatalogPicker({ } function StatusLine({ + isConsuming, pendingAction, status, }: { + isConsuming: boolean; pendingAction: "start" | "stop" | null; status: MeshNodeStatus | null; }) { @@ -500,6 +529,18 @@ function StatusLine({ if (pendingAction === "stop") { return

Stopping…

; } + // A client-mode runtime owns the single slot: this machine is consuming a + // peer's compute, not sharing. Explain why Share is off + disabled instead + // of showing the misleading "Sharing … with relay members" serve copy. The + // client node is app-session-lived infra with no user stop control, so the + // copy states the fact rather than promising an action that doesn't exist. + if (isConsuming) { + return ( +

+ This machine is currently using another member's shared compute. +

+ ); + } if (!status) { return

Checking status…

; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33e9e02ba..507b4e399 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1021,6 +1021,14 @@ declare global { admitted?: boolean; models?: Array<{ id: string; name: string | null }>; denyReason?: string; + /** Seed the runtime slot's lifecycle state (default "off"). */ + nodeState?: "off" | "running"; + /** + * Seed the runtime slot's role. "client" models this machine CONSUMING a + * peer's compute — it shares the single slot and reports state:"running", + * so the Share toggle must stay off. Drives the toggle-on regression test. + */ + nodeMode?: "serve" | "client" | null; }) => void; __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { agentPubkey: string; @@ -9066,6 +9074,8 @@ export function maybeInstallE2eTauriMocks() { if (mesh.models !== undefined) mockMeshState.models = mesh.models; if (mesh.denyReason !== undefined) mockMeshState.denyReason = mesh.denyReason; + if (mesh.nodeState !== undefined) mockMeshState.nodeState = mesh.nodeState; + if (mesh.nodeMode !== undefined) mockMeshState.nodeMode = mesh.nodeMode; }; let seedTurnSeq = Date.now(); window.__BUZZ_E2E_SEED_ACTIVE_TURNS__ = ({ @@ -9194,6 +9204,14 @@ export function maybeInstallE2eTauriMocks() { return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode); } case "mesh_stop_node": + // Mirror the backend contract: "stop sharing" must never tear down a + // client (consume) node occupying the single slot. Leave it running. + if (mockMeshState.nodeMode === "client") { + return meshNodeStatus( + mockMeshState.nodeState, + mockMeshState.nodeMode, + ); + } mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; return meshNodeStatus("off", null); diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index c33a53e34..28c4ee488 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -5,6 +5,10 @@ import { openSettings } from "../helpers/settings"; type E2eWindow = Window & { __BUZZ_E2E_COMMANDS__?: string[]; + __BUZZ_E2E_SET_MESH__?: (mesh: { + nodeState?: "off" | "running"; + nodeMode?: "serve" | "client" | null; + }) => void; }; test.beforeEach(async ({ page }) => { @@ -51,3 +55,44 @@ test("Share compute has a clear empty state and starts and stops sharing", async ) .toContain("mesh_stop_node"); }); + +test("consuming a peer's compute does NOT light the Share toggle", async ({ + page, +}) => { + // Regression: consuming someone else's shared compute starts a client-mode + // node in the single runtime slot, which reports state:"running". The Share + // toggle keyed off state alone and lit up — and clicking it would have torn + // down the unrelated consume session. It must stay off + disabled, explain + // why, and issue no stop command. + await page.goto("/"); + // The mesh seed hook is installed when the mock bridge boots; calling it + // before then silently no-ops (optional chaining) and the seed is lost. + await page.waitForFunction( + () => typeof (window as E2eWindow).__BUZZ_E2E_SET_MESH__ === "function", + ); + await page.evaluate(() => { + (window as E2eWindow).__BUZZ_E2E_SET_MESH__?.({ + nodeState: "running", + nodeMode: "client", + }); + }); + await openSettings(page, "compute"); + + const card = page.getByTestId("settings-mesh-share-compute"); + const toggle = page.getByTestId("mesh-share-compute-toggle"); + + await expect(card).toContainText( + "This machine is currently using another member's shared compute", + ); + await expect(toggle).not.toBeChecked(); + await expect(toggle).toBeDisabled(); + + // The switch is disabled, so a click can't fire onCheckedChange — but assert + // the destructive command never went out regardless. + const stopIssued = await page.evaluate(() => + ((window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []).includes( + "mesh_stop_node", + ), + ); + expect(stopIssued).toBe(false); +});