mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user