fix(desktop): Share toggle reflects serve mode, not slot occupancy

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
This commit is contained in:
Michael Neale
2026-07-23 10:12:25 +10:00
parent 87276320af
commit 21ed443e04
7 changed files with 345 additions and 7 deletions
+74 -2
View File
@@ -45,6 +45,18 @@ fn load_mesh_sharing_config(app: &AppHandle) -> Result<Option<MeshSharingConfig>
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<T> = Result<T, String>;
fn advance_mesh_status_cursor(
@@ -411,8 +423,22 @@ pub async fn mesh_stop_node(
app: AppHandle,
state: State<'_, AppState>,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
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();
+8
View File
@@ -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<MeshNodeStatus> {
let status = self.handle.status().await?;
self.status_from_sdk(status)
@@ -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);
});
@@ -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,
};
}
@@ -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
</label>
<StatusLine pendingAction={pendingAction} status={status} />
<StatusLine
isConsuming={isConsuming}
pendingAction={pendingAction}
status={status}
/>
</div>
<Switch
checked={isOn}
checked={isSharing}
data-testid="mesh-share-compute-toggle"
disabled={actionInFlight || (!isOn && !canStart)}
disabled={
// When the slot is occupied, the switch is only actionable if
// WE are sharing (so it can stop a serve node — even a failed
// one). Any other occupant (consuming, or an unexpected
// modeless-running node) would make a fresh start throw "already
// running", so keep it disabled. When the slot is empty, gate on
// a valid model ref.
actionInFlight || (slotOccupied ? !isSharing : !canStart)
}
id="mesh-share-compute-toggle"
onCheckedChange={handleToggle}
/>
@@ -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 <p className="text-sm text-muted-foreground">Stopping…</p>;
}
// 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 (
<p className="text-sm text-muted-foreground">
This machine is currently using another member's shared compute.
</p>
);
}
if (!status) {
return <p className="text-sm text-muted-foreground">Checking status…</p>;
}
+18
View File
@@ -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);
+45
View File
@@ -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);
});